diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..1974a13 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" +early_access: false +reviews: + profile: "assertive" + request_changes_workflow: false + high_level_summary: true + poem: true + review_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false +chat: + auto_reply: true diff --git a/.github/workflows/build_examples.yml b/.github/workflows/build_examples.yml index e7bf191..6e4d357 100644 --- a/.github/workflows/build_examples.yml +++ b/.github/workflows/build_examples.yml @@ -14,10 +14,13 @@ jobs: - uses: actions/checkout@v4 - name: configure run: cmake -D CMAKE_BUILD_TYPE=Release -B build_example_3 -S example_3 + - name: build example_3 run: cmake --build build_example_3 --config Release --target example_3 + - name: create example_3 package run: cmake --build build_example_3 --config Release --target package + - name: check content of *.deb package id: check-packages working-directory: build_example_3 @@ -31,6 +34,7 @@ jobs: echo "RPM_PACKAGE_FILENAME=${RPM_PACKAGE_FILENAME}" >> $GITHUB_OUTPUT echo "Checking content of '$DEBIAN_PACKAGE_FILENAME'" dpkg -c ${DEBIAN_PACKAGE_FILENAME} + - name: Release uses: softprops/action-gh-release@v2 ## if: startsWith(github.ref, 'refs/tags/') @@ -40,6 +44,15 @@ jobs: build_example_3/${{ steps.check-packages.outputs.DEBIAN_PACKAGE_FILENAME }} token: ${{ secrets.GITHUB_TOKEN }} tag_name: latest + + - name: Configure polymorphism + run: cmake -D CMAKE_BUILD_TYPE=Debug -B build_polymorphism -S polymorphism + + - name: Build polymorphism + run: cmake --build build_polymorphism --config Debug --target all --parallel + + - name: Test polymorphism + run: ctest --test-dir build_polymorphism --output-on-failure --build-config Debug # TODO: check https://github.com/marketplace/actions/cmake-swiss-army-knife # TODO: check https://github.com/actions/starter-workflows/blob/9f1db534549e072c20d5d1a79e0a4ff45a674caf/ci/cmake-multi-platform.yml#L20 diff --git a/polymorphism/CMakeLists.txt b/polymorphism/CMakeLists.txt new file mode 100644 index 0000000..a2cbc5a --- /dev/null +++ b/polymorphism/CMakeLists.txt @@ -0,0 +1,24 @@ +cmake_minimum_required(VERSION 3.29) + +project(polymorphism VERSION 1.0.0 LANGUAGES CXX) + +# Enable testing by default when this is the top-level project +option(POLYMORPHISM_ENABLE_TESTING "enable testing for polymorphism" ${PROJECT_IS_TOP_LEVEL}) + +add_executable(${PROJECT_NAME} + src/consume_class_with_interface.cpp + src/consume_class_that_adheres_to_concept.cpp + src/main.cpp +) + +target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_23) +target_include_directories(${PROJECT_NAME} PUBLIC include) +target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + +if(POLYMORPHISM_ENABLE_TESTING) + message(STATUS "Polymorphism testing enabled") + enable_testing() + add_subdirectory(test) +else() + message(STATUS "Polymorphism testing disabled") +endif() diff --git a/polymorphism/README.md b/polymorphism/README.md new file mode 100644 index 0000000..252a79b --- /dev/null +++ b/polymorphism/README.md @@ -0,0 +1,193 @@ +# C++ Polymorphism Example + +## Summary + +This example shows two different ways to implement [polymorphism](https://en.wikipedia.org/wiki/Polymorphism_(computer_science)) in C++: + +1. The classic approach using an [abstract class](https://en.cppreference.com/w/cpp/language/abstract_class) which is used to define an interface and from which the implementation inherits. +2. The modern approach using [concepts](https://en.cppreference.com/w/cpp/language/constraints) to declare features a type must have. + +In addition, this directory contains an example about how to perform unit tests using the [ut testing framework](https://github.com/boost-ext/ut). + +## Build Instructions + +### Preparation + +Create a subdirectory `build_polymorphism` and prepare the build system for the source located in `polymorphism` in this subdirectory: For release builds use + +```bash +cmake -D CMAKE_BUILD_TYPE=Release -B build_polymorphism -S polymorphism +``` +For debug builds use + +```bash +cmake -D CMAKE_BUILD_TYPE=Debug -B build_polymorphism -S polymorphism +``` + +### Building the `polymorphism` project + +Call `cmake` from the top level like this: + +```bash +cmake --build build_polymorphism --config Release --target polymorphism --parallel +``` + +## Test Instructions + +### Build the tests + +```bash +VERBOSE=1 cmake --build build_polymorphism --clean-first --config Debug --target all --parallel +``` + +### Run the tests + +```bash +ctest --test-dir build_polymorphism --output-on-failure --build-config Debug +``` + +## Code Explanations + +### Classic Approach + +In classic C++, interfaces are defined via [abstract base classes](https://en.cppreference.com/w/cpp/language/abstract_class) from which the implementation (publicly) inherits. In this example, we have defined such an abstract class: + +```c++ +class ISuperCoolFeatures { +public: + virtual std::string coolFeature() const = 0; // Pure virtual function + virtual void set(std::string s) = 0; // Pure virtual function + virtual ~ISuperCoolFeatures() = default; // Virtual destructor +}; +``` + +Instead of defining the member function body (which is possible in a regular class), the member functions of an abstract interface class are declared "pure virtual" by adding `= 0` to their declaration. + +Any so-called **implementation** of the interface publicly inherits from this abstract base class and then declares and defines member functions that match the declarations of the pure virtual functions in the abstract interface class: + +```c++ +class Impl + : public ISuperCoolFeatures { + +// ... private members and member functions omitted ... + +public: + std::string coolFeature() const override { return s_; } + void set(std::string s) override + { + // ... implementation omitted ... + } +}; +``` + +A variable (aka instantiation) of type `Impl` can be passed as argument to any function that expects an argument of its interface type `ISuperCoolFeatures`. + +In our example, we define a function which has one argument of type `ISuperCoolFeatures`, and returns a `std::string`: + +```c++ +std::string consume(ISuperCoolFeatures& f); +``` + +We then pass an argument of type `Impl` to it, e.g. like this: + +```c++ +Impl i; +consume(i); +``` + +Since it is best practice, the example code puts the type and function definitions into namespaces. + +### Modern Approach + +In modern C++, interfaces can also be defined via [concepts](https://en.cppreference.com/w/cpp/language/constraints), which can be used to declare what features a concrete class must have. It is slightly more powerful in what can be described. + +It also has a great advantage that it is non-intrusive, i.e. it does not require a concrete class to inherit from a certain interface class. This is of advantage when you are not owner of the code of a class that you want to use and cannot change its declaration and definition. + +This means that objects of completely unrelated classes can be passed as arguments to a member function (aka method) or a free function, if they all have certain member functions defined. + +From a code structuring point of view, this means that the cohesion between different parts of the code is reduced. + +In this example, we have defined such an interface specification as a concept: + +```c++ +template +concept has_super_cool_features = requires(T t, std::string s) { + { t.coolFeature() } -> std::convertible_to; + { t.set(s) } -> std::same_as; +}; +``` + +We then declare a function that takes arguments whose type adheres to the constraints defined by the concept: + +```c++ +std::string consume(has_super_cool_features auto& s); +``` + +and can use it in the very same way like in the classic case: + +```c++ +Impl i; +consume(i); +``` + +In this approach, our implementation is defined **without** inheritance: + +```c++ +class Impl { + +// ... + +public: + std::string coolFeature() const { /* ... some code ... */ } + void set(std::string s) + { + // code omitted + } +}; +``` + +To catch errors in the implementation before the class gets uses somewhere else in the code, it suffices to check against the concept like this: + +```c++ +static_assert(has_super_cool_features); +``` + +Please note, that this modern approach with its advantages in code decoupling, flexibility and a more powerful description of its features comes at a price: + +Functions that use ` auto` as parameter type are *generic* and this requires either their implicit or their explicit instantiation. + +Implicit instantiation of the function can take place if its **definition** is visible at the point of usage. + +In this example, we use explicit instantiation to show the possibility to clearly separate the definition (implementation) of the generic function from its declaration. + +You can find the definition of `consume` in `src/consume_class_that_adheres_to_concept.ipp` which is then included into `src/consume_class_that_adheres_to_concept.cpp`. In the latter file we also place all explicit instantiations of `consume`: + +```c++ +// explicit template instantiation +template std::string consume(Impl&); +``` + +As an alternative, the *definition* of `consume` could be in the header `include/polymorphism/consume_class_that_adheres_to_concept.hpp`. Then the explicit template instantiation is no longer required. + +## Testing + +In this example, I use [µt](https://github.com/boost-ext/ut) as testing framework for no good reason. It looks promising and lightweight, and it was written by [Krzysztof Jusiak](https://github.com/krzysztof-jusiak) whom I adore for his outstanding C++ programming skills and his [C++ Tip of The Week](https://github.com/tip-of-the-week/cpp) collection of crazy C++ riddles. + +The one-header version of it is pulled into the project via the CMake call + +```cmake +FetchContent_Declare(ut + URL https://raw.githubusercontent.com/boost-ext/ut/refs/heads/master/include/boost/ut.hpp + DOWNLOAD_NO_EXTRACT TRUE + SOURCE_DIR ut +) +FetchContent_MakeAvailable(ut) +``` + +and the directory to which the header is downloaded is added to the search path set for the build of the test executables: + +```cmake +target_include_directories(test_consume PUBLIC "${ut_SOURCE_DIR}") +``` + +Please refer to [this slidedeck](https://boost-ext.github.io/ut/denver-cpp-2019/#/) for further documentation of this test library. diff --git a/polymorphism/include/polymorphism/consume_class_that_adheres_to_concept.hpp b/polymorphism/include/polymorphism/consume_class_that_adheres_to_concept.hpp new file mode 100644 index 0000000..4a7f695 --- /dev/null +++ b/polymorphism/include/polymorphism/consume_class_that_adheres_to_concept.hpp @@ -0,0 +1,15 @@ +#ifndef POYMORPHISM_CONSUME_CLASS_THAT_ADHERES_TO_CONCEPT_HPP +#define POYMORPHISM_CONSUME_CLASS_THAT_ADHERES_TO_CONCEPT_HPP + +#include + +#include + +namespace modern { + +// declaration with concept constraint +std::string consume(has_super_cool_features auto& s); + +} // namespace modern + +#endif // POYMORPHISM_CONSUME_CLASS_THAT_ADHERES_TO_CONCEPT_HPP \ No newline at end of file diff --git a/polymorphism/include/polymorphism/consume_class_with_interface.hpp b/polymorphism/include/polymorphism/consume_class_with_interface.hpp new file mode 100644 index 0000000..7c774a8 --- /dev/null +++ b/polymorphism/include/polymorphism/consume_class_with_interface.hpp @@ -0,0 +1,12 @@ +#ifndef POLYMORPHISM_CONSUME_CLASS_WITH_INTERFACE_HPP +#define POLYMORPHISM_CONSUME_CLASS_WITH_INTERFACE_HPP + +#include + +namespace classic { + +std::string consume(ISuperCoolFeatures& f); + +} // namespace classic + +#endif // POLYMORPHISM_CONSUME_CLASS_WITH_INTERFACE_HPP \ No newline at end of file diff --git a/polymorphism/include/polymorphism/has_super_cool_features.hpp b/polymorphism/include/polymorphism/has_super_cool_features.hpp new file mode 100644 index 0000000..4810bcb --- /dev/null +++ b/polymorphism/include/polymorphism/has_super_cool_features.hpp @@ -0,0 +1,17 @@ +#ifndef POYMORPHISM_HAS_SUPER_COOL_FEATURES_HPP +#define POYMORPHISM_HAS_SUPER_COOL_FEATURES_HPP + +#include + +namespace modern { + +/// @brief A concept defintion describing a class or struct that has two member functions. +template +concept has_super_cool_features = requires(T t, std::string s) { + { t.coolFeature() } -> std::convertible_to; + { t.set(s) } -> std::same_as; +}; + +} // namespace modern + +#endif // POYMORPHISM_HAS_SUPER_COOL_FEATURES_HPP \ No newline at end of file diff --git a/polymorphism/include/polymorphism/i_super_cool_features.hpp b/polymorphism/include/polymorphism/i_super_cool_features.hpp new file mode 100644 index 0000000..06356ca --- /dev/null +++ b/polymorphism/include/polymorphism/i_super_cool_features.hpp @@ -0,0 +1,24 @@ +#ifndef POLYMORPHISM_I_SUPER_COOL_FEATURES_HPP +#define POLYMORPHISM_I_SUPER_COOL_FEATURES_HPP + +#include + +namespace classic { + +/// @brief A typical interface definition via a class with pure virtual functions/methods. +/// +/// This interface demonstrates classic polymorphism in C++. +/// +/// @details +/// - coolFeature(): Returns a string representing some cool feature. +/// - set(std::string s): Sets some internal state of the implementing class. +class ISuperCoolFeatures { +public: + [[nodiscard]] virtual std::string coolFeature() const = 0; // Pure virtual function + virtual void set(std::string s) = 0; // Pure virtual function + virtual ~ISuperCoolFeatures() = default; // Virtual destructor +}; + +} // namespace classic + +#endif // POLYMORPHISM_I_SUPER_COOL_FEATURES_HPP \ No newline at end of file diff --git a/polymorphism/include/polymorphism/impl_with_interface.hpp b/polymorphism/include/polymorphism/impl_with_interface.hpp new file mode 100644 index 0000000..7cccbd0 --- /dev/null +++ b/polymorphism/include/polymorphism/impl_with_interface.hpp @@ -0,0 +1,26 @@ +#ifndef POLYMORPHISM_IMPL_WITH_INTERFACE_HPP +#define POLYMORPHISM_IMPL_WITH_INTERFACE_HPP + +// make the inteface defintion visible +#include + +#include + +namespace classic { + +class Impl + : public ISuperCoolFeatures { +private: + std::string s_ { "" }; // with default member initializer (C++11) + +public: + std::string coolFeature() const noexcept override { return s_; } + void set(std::string s) noexcept override + { + s_ = std::move(s); + } +}; + +} // namespace classic + +#endif // POLYMORPHISM_IMPL_WITH_INTERFACE_HPP \ No newline at end of file diff --git a/polymorphism/include/polymorphism/impl_without_interface.hpp b/polymorphism/include/polymorphism/impl_without_interface.hpp new file mode 100644 index 0000000..054e2b0 --- /dev/null +++ b/polymorphism/include/polymorphism/impl_without_interface.hpp @@ -0,0 +1,28 @@ +#ifndef POLYMORPHISM_IMPL_WITHOUT_INTERFACE_HPP +#define POLYMORPHISM_IMPL_WITHOUT_INTERFACE_HPP + +#include + +#include + +namespace modern { + +class Impl { +private: + std::string s_ { "" }; // with default member initializer (C++11) + +public: + [[nodiscard]] std::string coolFeature() const noexcept { return s_; } + void set(std::string s) noexcept + { + s_ = std::move(s); + } +}; + +// Check if the class adheres to the concept (i.e. has the interface we want it to have). +static_assert(has_super_cool_features, + "Impl class does not meet the requirements of the has_super_cool_features concept"); + +} // namespace modern + +#endif // POLYMORPHISM_IMPL_WITHOUT_INTERFACE_HPP \ No newline at end of file diff --git a/polymorphism/src/consume_class_that_adheres_to_concept.cpp b/polymorphism/src/consume_class_that_adheres_to_concept.cpp new file mode 100644 index 0000000..4807578 --- /dev/null +++ b/polymorphism/src/consume_class_that_adheres_to_concept.cpp @@ -0,0 +1,9 @@ +#include +#include + +namespace modern { + +// Explicit template instantiation +template std::string consume(Impl&); + +} diff --git a/polymorphism/src/consume_class_that_adheres_to_concept.ipp b/polymorphism/src/consume_class_that_adheres_to_concept.ipp new file mode 100644 index 0000000..7e6ec8c --- /dev/null +++ b/polymorphism/src/consume_class_that_adheres_to_concept.ipp @@ -0,0 +1,20 @@ +#ifndef CONSUME_CLASS_THAT_ADHERES_TO_CONCEPT_IPP +#define CONSUME_CLASS_THAT_ADHERES_TO_CONCEPT_IPP + +#include + +#include + +namespace modern { + +// declaration with concept constraint +std::string consume(has_super_cool_features auto& f) +{ + auto answer = "42"; + f.set(answer); + return "The answer to all questions is " + f.coolFeature(); +} + +} // namespace modern + +#endif // CONSUME_CLASS_THAT_ADHERES_TO_CONCEPT_IPP \ No newline at end of file diff --git a/polymorphism/src/consume_class_with_interface.cpp b/polymorphism/src/consume_class_with_interface.cpp new file mode 100644 index 0000000..d503c3a --- /dev/null +++ b/polymorphism/src/consume_class_with_interface.cpp @@ -0,0 +1,12 @@ +#include + +namespace classic { + +std::string consume(ISuperCoolFeatures& f) +{ + auto answer = "42"; + f.set(answer); + return "The answer to all questions is " + f.coolFeature(); +} + +} // namespace classic \ No newline at end of file diff --git a/polymorphism/src/main.cpp b/polymorphism/src/main.cpp new file mode 100644 index 0000000..a927398 --- /dev/null +++ b/polymorphism/src/main.cpp @@ -0,0 +1,21 @@ +#include +#include +#include +#include + +#include // the legacy way to print stuff + +int main() +{ + { + classic::Impl i; + std::cerr << classic::consume(i) << '\n'; + } + + { + modern::Impl i; + std::cerr << modern::consume(i) << '\n'; + } + + return EXIT_SUCCESS; +} \ No newline at end of file diff --git a/polymorphism/test/CMakeLists.txt b/polymorphism/test/CMakeLists.txt new file mode 100644 index 0000000..d351f0c --- /dev/null +++ b/polymorphism/test/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.29) + +include(FetchContent) + +FetchContent_Declare(ut + URL https://raw.githubusercontent.com/boost-ext/ut/refs/heads/master/include/boost/ut.hpp + DOWNLOAD_NO_EXTRACT TRUE + SOURCE_DIR ut +) +FetchContent_MakeAvailable(ut) + +add_library(polymorphism_lib + ../src/consume_class_that_adheres_to_concept.cpp + ../src/consume_class_with_interface.cpp +) + +target_compile_features(polymorphism_lib PUBLIC cxx_std_23) +target_include_directories(polymorphism_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../include) +target_include_directories(polymorphism_lib PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src) + +add_executable(test_consume + test_consume.cpp +) + +target_compile_features(test_consume PUBLIC cxx_std_23) + +target_include_directories(test_consume PUBLIC ../include) +target_include_directories(test_consume PUBLIC "${ut_SOURCE_DIR}") + +target_link_libraries(test_consume PRIVATE polymorphism_lib) + +add_test( + NAME test_consume + COMMAND test_consume + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} +) \ No newline at end of file diff --git a/polymorphism/test/test_consume.cpp b/polymorphism/test/test_consume.cpp new file mode 100644 index 0000000..1d7f51b --- /dev/null +++ b/polymorphism/test/test_consume.cpp @@ -0,0 +1,46 @@ +#include + +#include +#include +#include +#include + +int main() +{ + using namespace boost::ut; + using namespace boost::ut::bdd; + using namespace std::string_literals; + + "classic interface call yields 42"_test = [] { + classic::Impl i; + expect(""s == i.coolFeature()); + expect("The answer to all questions is 42"s == classic::consume(i)); + }; + + "modern interface call yields 42"_test = [] { + modern::Impl i; + expect(""s == i.coolFeature()); + expect("The answer to all questions is 42"s == modern::consume(i)); + expect("42"s == i.coolFeature()); + }; + + "[modern]"_test + = [] { + given("I have a an implementation that adheres to a concept") = [] { + modern::Impl impl; + expect(""s == impl.coolFeature()); + + when("I pass it to a function that expects an argument that fulfils the constraints") = [&] { + auto result = modern::consume(impl); + + then("the answer to all questions should be given") = [=] { + expect("The answer to all questions is 42"s == result); + }; + + then("the state of the argument should be modified as a side effect") = [=] { + expect("42"s == impl.coolFeature()); + }; + }; + }; + }; +}