Chapter 4 - Separation of Concerns - Software Architecture, Functions, Variadic Templates
Activity 1: Implement a multicast event handler
- Load the prepared project from the Lesson4/Activity01 folder and configure the Current Builder for the project to be CMake Build (Portable). Build the project, configure the launcher and run the unit tests (which fail the one dummy test). Recommend that the name used for the tests runner is L4delegateTests.
- In delegateTests.cpp, replace the failing dummy test with the following test:
TEST_F(DelegateTest, BasicDelegate)
{
Delegate delegate;
ASSERT_NO_THROW(delegate.Notify(42));
}
- This now fails to build, so we need to add a new method to Delegate. As this will evolve into a template, we will do all of this development in the header file. In delegate.hpp, and the following definition:
class Delegate
{
public:
Delegate() = default;
void Notify(int value) const
{
}
};
The test now runs and passes.
- Add the following line to the existing test:
ASSERT_NO_THROW(delegate(22));
- Again, the build fails, so we update the Delegate definition as follows (we could have had Notify call operator(), but this is easier to read):
void operator()(int value)
{
Notify(value);
}
The test again runs and passes.
- Before we add the next test, we are going to add some infrastructure to help us develop our tests. The easiest thing to do with handlers is have them write to std::cout, and to be able to verify that they were called, we need to capture the output. To do this, re-route the standard output stream to a different buffer by changing the DelegateTest class as follows:
class DelegateTest : public ::testing::Test
{
public:
void SetUp() override;
void TearDown() override;
std::stringstream m_buffer;
// Save cout's buffer here
std::streambuf *m_savedBuf{};
};
void DelegateTest::SetUp()
{
// Save the cout buffer
m_savedBuf = std::cout.rdbuf();
// Redirect cout to our buffer
std::cout.rdbuf(m_buffer.rdbuf());
}
void DelegateTest::TearDown()
{
// Restore cout buffer to original
std::cout.rdbuf(m_savedBuf);
}
- Also add the include statements for <iostream>, <sstream> and <string> to the top of the file.
- With this support framework in place, add the following test:
TEST_F(DelegateTest, SingleCallback)
{
Delegate delegate;
delegate += [] (int value) { std::cout << "value = " << value; };
delegate.Notify(42);
std::string result = m_buffer.str();
ASSERT_STREQ("value = 42", result.c_str());
}
- To make the tests build and run again, add the following code in the delegate.h class:
Delegate& operator+=(const std::function<void(int)>& delegate)
{
m_delegate = delegate;
return *this;
}
Along with the following code:
private:
std::function<void(int)> m_delegate;
The tests now build, but our new test fails.
- Update the Notify() method to be:
void Notify(int value) const
{
m_delegate(value);
}
- The tests now build and our new test passes, but the original test now fails. The call to the delegate is throwing an exception, so we need to check that the delegate is not empty before calling it. Write the following code to do this:
void Notify(int value) const
{
if(m_delegate)
m_delegate(value);
}
All the tests now run and pass.
- We now need to add multicast support to the Delegate class. Add the new test:
TEST_F(DelegateTest, DualCallbacks)
{
Delegate delegate;
delegate += [] (int value) { std::cout << "1: = " << value << "\n"; };
delegate += [] (int value) { std::cout << "2: = " << value << "\n"; };
delegate.Notify(12);
std::string result = m_buffer.str();
ASSERT_STREQ("1: = 12\n2: = 12\n", result.c_str());
}
- Of course, this test now fails because the operator+=() only assigns to the member variable. We need to add a list to store our delegates. We choose vector so we can add to the end of the list as we want to call the delegates in the order that they are added. Add #include <vector> to the top of delegate.hpp and update Delegate replace m_delegate with m_delegates vector of the callbacks:
class Delegate
{
public:
Delegate() = default;
Delegate& operator+=(const std::function<void(int)>& delegate)
{
m_delegates.push_back(delegate);
return *this;
}
void Notify(int value) const
{
for(auto& delegate : m_delegates)
{
delegate(value);
}
}
void operator()(int value)
{
Notify(value);
}
private:
std::vector<std::function<void(int)>> m_delegates;
};
The tests all run and pass again.
- We now have the basic multicast delegate class implemented. We now need to convert it to a template- based class. Update the existing tests, by changing all of the declarations of Delegate to Delegate<int> in the three tests.
- Now update the Delegate class by adding template<class Arg> before the class to convert it to a template, and substituting the four occurrences of int with Arg:
template<class Arg>
class Delegate
{
public:
Delegate() = default;
Delegate& operator+=(const std::function<void(Arg)>& delegate)
{
m_delegates.push_back(delegate);
return *this;
}
void Notify(Arg value) const
{
for(auto& delegate : m_delegates)
{
delegate(value);
}
}
void operator()(Arg value)
{
Notify(value);
}
private:
std::vector<std::function<void(Arg)>> m_delegates;
};
- All the tests now run and pass as previously, so it stills works for int arguments for the handlers.
- Add the following test and re-run the tests to confirm that the template conversion is correct:
TEST_F(DelegateTest, DualCallbacksString)
{
Delegate<std::string&> delegate;
delegate += [] (std::string value) { std::cout << "1: = " << value << "\n"; };
delegate += [] (std::string value) { std::cout << "2: = " << value << "\n"; };
std::string hi{"hi"};
delegate.Notify(hi);
std::string result = m_buffer.str();
ASSERT_STREQ("1: = hi\n2: = hi\n", result.c_str());
}
- Now it operates as a template that takes one argument. We need to convert it into a variadic template that takes zero or more arguments. Using the information from the last topic, update the template to the following:
template<typename... ArgTypes>
class Delegate
{
public:
Delegate() = default;
Delegate& operator+=(const std::function<void(ArgTypes...)>& delegate)
{
m_delegates.push_back(delegate);
return *this;
}
void Notify(ArgTypes&&... args) const
{
for(auto& delegate : m_delegates)
{
delegate(std::forward<ArgTypes>(args)...);
}
}
void operator()(ArgTypes&&... args)
{
Notify(std::forward<ArgTypes>(args)...);
}
private:
std::vector<std::function<void(ArgTypes...)>> m_delegates;
};
The tests should still run and pass.
- Add two more tests – zero argument test, and a mutliple argument test:
TEST_F(DelegateTest, DualCallbacksNoArgs)
{
Delegate delegate;
delegate += [] () { std::cout << "CB1\n"; };
delegate += [] () { std::cout << "CB2\n"; };
delegate.Notify();
std::string result = m_buffer.str();
ASSERT_STREQ("CB1\nCB2\n", result.c_str());
}
TEST_F(DelegateTest, DualCallbacksStringAndInt)
{
Delegate<std::string&, int> delegate;
delegate += [] (std::string& value, int i) {
std::cout << "1: = " << value << "," << i << "\n"; };
delegate += [] (std::string& value, int i) {
std::cout << "2: = " << value << "," << i << "\n"; };
std::string hi{"hi"};
delegate.Notify(hi, 52);
std::string result = m_buffer.str();
ASSERT_STREQ("1: = hi,52\n2: = hi,52\n", result.c_str());
}
All the tests run and pass showing that we have now implemented the desired Delegate class.
- Now, change the Run configuration to execute the program L4delegate. Open the main.cpp file in the editor and change the definition at the top of the file to the following and run the program:
#define ACTIVITY_STEP 27
We get the following output:
Figure 4.35: Output from the successful implementation of Delegate
In this activity, we started by implementing a class that provides the basic single delegate functionality and then added multicast capability. With that implemented, and unit tests in place, we were quickly able to convert to a template with one argument and then to a variadic template version. Depending on the functionality that you are developing, the approach of the specific implementation transitioning to a general form and then to an even more general form is the correct one. Development of variadic templates is not always obvious.