Break the coupling in C++
01 November 2020
Implementations tightly coupled to their dependencies are difficult to maintain,
reuse and test.
In this tutorial, I will show you 5 ways of refactoring with the intent to
“break” tight coupling between business logic and dependencies.
A good indicator over the effectiveness of a decoupling is testability: If you are
able to test your class and your class only without being influenced by the inner
works of the various dependencies, then you know you have done a good decoupling.
Below you will find some tightly coupled code and several ways to break it away from
its dependencies. The focus will be mostly on the code, however, the build configuration
is often also very important. Being decoupled on the code level but not in the
CMake/Bazel/Blueprint files will not get you very far. Last but not least, for every
refactored example you will find the unit tests that accompany it.
Tightly coupled code example
Here is some typical code that is coupled with its dependencies. It will be refactored to demonstrate the different ways you can decouple troublesome code.
CameraPowerController.h
class CameraPowerController
{
public:
CameraPowerController(ProductVariant productVariant);
void turnOnCamera();
void turnOffCamera();
private:
std::unique_ptr<AsioSerialPortManager> mAsioSerialPortManager;
};
AsioSerialPortManager
, as its name implies, is a concrete class that utilizes a
3rd-party library.
CameraPowerController.cpp
CameraPowerController::CameraPowerController(ProductVariant productVariant)
{
switch (productVariant)
{
case ProductVariant::A:
mAsioSerialPortManager = std::make_unique<AsioSerialPortManager>(
kSerialDevicePathForVariantA, kBaudRateForVariantA);
return;
case ProductVariant::B:
mAsioSerialPortManager = std::make_unique<AsioSerialPortManager>(
kSerialDevicePathForVariantB, kBaudRateForVariantB);
return;
default:
throw std::logic_error("Unknown variant");
}
}
void CameraPowerController::turnOnCamera()
{
mAsioSerialPortManager->asioWrite("ON");
}
In the CameraPowerController
implementation, we invoke the constructor of AsioSerialPortManager
with some arguments. These arguments are determined based on logic residing within the class. Then,
we call the instantiated class in the other member functions.
Whenever we invoke constructors of other classes, we become get coupled to them. In fact, this is
very often a red flag in regards to the testability of the class. Testability is decreased
because we become obliged to indirectly test them as well along with the unit we actual want to test.
Moreover, compiling and running a binary that involves them may be cumbersome or even impossible in
our unit testing environment.
For example, the particular classes may not be compilable for the host platform that runs the
unit tests. This is common when dependencies include resources that are precompiled for a different
operating system or architecture. Additionally, even if the dependent classes are compilable, it
may be impossible to execute them on our host platform, due to them requiring some specific resources
that are only found on a target system. In our case, the AsioSerialPortManager
class needs to
open a connection to a serial device that will only exist on the target we are developing for and not
on the developer’s computer.
In other words, if we would like to unit test our CameraPowerController
class and have the unit tests
run on our development machines there is no other option than decoupling it from AsioSerialPortManager
.
Dependency Injection (Polymorphic)
The cleanest (IMHO) way to decouple dependencies is to inject their abstractions and use those instead. If such abstractions do not already exist, this means that you have to define an interface (i.e. a pure abstract class) that represents the business value the dependencies offer in a rather generic way. Then, you can have the dependency you need inherit/implement it. Finally, make the constructor of the class under test receive the interface as an argument.
SerialPortAdapter.h
The most straight forward way perhaps is to create an abstract interface directly out of the dependency.
However, this is a bad practice since you will be indirectly coupling your code with the particular implementation.
Instead, I suggest you try to figure out what is the business value in a generic manner. With this in mind,
I created the SerialPortAdapter
interface that generalizes any class that works with the serial port and
exposes a way to transmit data in a generic manner.
struct SerialPortAdapter
{
virtual ~SerialPortAdapter() = default;
virtual void send(std::string_view message) = 0;
};
AsioSerialPortAdapter
Then we have the AsioSerialPortAdapter
concrete class that inherits from SerialPortAdapter
and exposes
the AsioSerialPortManager
in a generic manner.
class AsioSerialPortAdapter : public SerialPortAdapter
{
public:
AsioSerialPortAdapter(AsioSerialPortManager* asioSerialPortManager);
void send(std::string_view message) override;
private:
AsioSerialPortManager* mAsioSerialPortManager;
};
AsioSerialPortAdapter::AsioSerialPortAdapter(
AsioSerialPortManager* asioSerialPortManager)
: mAsioSerialPortManager{asioSerialPortManager}
{
}
void AsioSerialPortAdapter::send(std::string_view message)
{
mAsioSerialPortManager->asioWrite(message);
}
CameraPowerController.h
In our CameraPowerController
we inject the SerialPortAdapter
. Now, there is nothing specific to the asio
library.
We are effectively decoupled from it.
class CameraPowerController
{
public:
CameraPowerController(SerialPortAdapter* serialPortAdapter);
void turnOnCamera();
void turnOffCamera();
private:
SerialPortAdapter* mSerialPortAdapter;
};
di_polymorphism_main.cpp
Now that we are injecting things, why not follow the Inversion of Control (IoC) principle all the way?
The serial port configuration is probably not a concern of the CameraPowerController
class, however it takes
place in its constructor. Now that we are injecting resources and letting the users of our classes have control,
we are indirectly encouraged to move these seemingly unrelated functionality outside the class.
std::pair<std::filesystem::path, int>
getAsioSerialPortManagerConfiguration(ProductVariant productVariant)
{
switch (productVariant)
{
case ProductVariant::A:
return std::make_pair(kSerialDevicePathForVariantA,
kBaudRateForVariantA);
case ProductVariant::B:
return std::make_pair(kSerialDevicePathForVariantB,
kBaudRateForVariantB);
default:
throw std::logic_error("Unknown variant");
}
}
int main()
{
const auto [serialDevice, baudRate]
= getAsioSerialPortManagerConfiguration(getProductVariant());
AsioSerialPortManager asioSerialPortManager{serialDevice, baudRate};
AsioSerialPortAdapter asioSerialPortAdapter{&asioSerialPortManager};
CameraPowerController cameraPowerController{&asioSerialPortAdapter};
cameraPowerController.turnOnCamera();
cameraPowerController.turnOffCamera();
return 0;
}
Dependency Injection (Templatized)
If many levels of indirection and virtual
functions are not desirable (e.g. due to strict requirements on performance)
then injecting your dependencies via the constructor is still the way to go. However, this time you will not use a class hierarchy.
Instead, you can use a class template to determine the type of the constructor argument.
This allows for a looser coupling that is determined by the one that instantiates the class and not the class itself.
CameraPowerController.h
template<typename SerialPortManager>
class CameraPowerController
{
public:
CameraPowerController(SerialPortManager* serialPortManager)
: mSerialPortManager{serialPortManager}
{
}
void turnOnCamera()
{
mSerialPortManager->asioWrite("ON");
}
void turnOffCamera()
{
mSerialPortManager->asioWrite("OFF");
}
private:
SerialPortManager* mSerialPortManager;
};
As long as the class template type implements an API equivalent to the AsioSerialPortManager
then our coupling
is only in the design/conceptual level. This is not ideal, however can be good enough for many of the cases. Furthermore,
it should be noted that yet again we have moved the configuration of the serial port outside the class.
Dependency Injection (Abstract factory)
If there are really good reasons to give a class control over (some of) its resources then it may not be possible to
instantiate its dependencies in the integration scope (e.g. a main()
function) because they require some information
that resides within the class that uses them.
A good example is the original CameraPowerController
constructor:
CameraPowerController::CameraPowerController(ProductVariant productVariant)
{
switch (productVariant)
{
case ProductVariant::A:
mSerialPortManager = std::make_unique<AsioSerialPortManager>(
kSerialDevicePathForVariantA, kBaudRateForVariantA);
return;
case ProductVariant::B:
mSerialPortManager = std::make_unique<AsioSerialPortManager>(
kSerialDevicePathForVariantB, kBaudRateForVariantB);
return;
default:
throw std::logic_error("Unknown variant");
}
}
AsioSerialPortManager
requires two arguments to be passed to its constructor that are owned by the class that
uses it. To be honest with you, I consider this a design smell. However, if you are really convinced something like this
makes sense, then you can inject an abstract factory class to keep your implementation decoupled from its dependencies.
The abstract factory class takes the arguments that would have otherwise been supplied to the concrete class’ constructor
and returns an instance of the concrete class. The “trick” is that our class depends on an abstraction returned by the
factory function call, not a particular implementation.
AsioSerialPortManager.h
class AsioSerialPortManager : public SerialPortManager
{
public:
AsioSerialPortManager(std::filesystem::path serialDevice, int baudRate);
void asioWrite(std::string_view message) override;
private:
asio::io_service mIoService;
asio::serial_port mSerialPort{mIoService};
};
SerialPortManager
is a pure abstract class that generalizes AsioSerialPortManager
.
AsioSerialPortManagerFactory
The SerialPortManagerFactory
pure abstract class “promises” its children to return a child/specialization of the (also abstract) SerialPortManager
.
class AsioSerialPortManagerFactory : public SerialPortManagerFactory
{
public:
std::unique_ptr<SerialPortManager> get(std::filesystem::path serialDevice,
int baudRate) const override;
};
Unsurprisingly, AsioSerialPortManagerFactory
returns an AsioSerialPortManager
instance.
std::unique_ptr<SerialPortManager>
AsioSerialPortManagerFactory::get(std::filesystem::path serialDevice,
int baudRate) const
{
return std::make_unique<AsioSerialPortManager>(serialDevice, baudRate);
}
CameraPowerController.cpp
Our CameraPowerController
class maintains ownership of the resources needed for the instantiation of its dependency but
no longer depends on it. Instead, it depends on the abstraction (i.e. SerialPortManager
) returned by the get
call.
CameraPowerController::CameraPowerController(
SerialPortManagerFactory* serialPortManagerFactory,
ProductVariant productVariant)
{
switch (productVariant)
{
case ProductVariant::A:
mSerialPortManager = serialPortManagerFactory->get(
kSerialDevicePathForVariantA, kBaudRateForVariantA);
return;
case ProductVariant::B:
mSerialPortManager = serialPortManagerFactory->get(
kSerialDevicePathForVariantB, kBaudRateForVariantB);
return;
default:
throw std::logic_error("Unknown variant");
}
}
Link time switching
Occasionally it may not be feasible or practical to refactor existing code but you still wish to break the coupling between
the implementation and its dependencies. What you can do, is continue depending on the same declarations during compile time
but “replace” the definitions of your dependencies when linking.
Since your code still depends on the same exposed functions and types, you do not need to change it. Instead, when linking you
can provide an alternative implementation (e.g. a mock). This time, the “magic” happens on the configuration level.
Coupled configuration
# CameraPowerController
add_library(camera_power_controller src/CameraPowerController.cpp)
target_include_directories(camera_power_controller PUBLIC include)
target_link_libraries(camera_power_controller
PUBLIC
asio_serial_port_manager
product_variant
)
# src_main
add_executable(src_main main.cpp)
target_link_libraries(src_main
PRIVATE
camera_power_controller
)
On a configuration level our camera_power_controller
target is tightly coupled to the asio_serial_port_manager
one.
No matter what we do on the code level, we will be pulling in the dependency.
Decoupled configuration
target_include_directories(link_switch_camera_power_controller PUBLIC include)
target_link_libraries(link_switch_camera_power_controller
PUBLIC
asio_serial_port_manager_interface
product_variant
)
To break the dependency to the asio_serial_port_manager
target, we instead depend on the
asio_serial_port_manager_interface
which only includes the header files necessary. This means that compilation will work as before.
However, we still need the actual definitions during linking to build a binary. The definitions will be supplied in the integration
scope, i.e. the target that builds the executable.
# src_main
add_executable(link_switch_main link_switch_main.cpp)
target_link_libraries(link_switch_main
PRIVATE
link_switch_camera_power_controller
asio_serial_port_manager
)
The link_switch_main
target, as the one that creates the executable, is responsible for bringing everything together and making
sure that the necessary resources are available for linking. This is eventually the target that depends on asio_serial_port_manager
.
After decoupling, you can provide alternative implementations for the dependencies that will run on different platforms,
e.g. during unit tests. Check out how we would now test CameraPowerController
:
- Test configuration
- Mock
- Alternative
AsioSerialPortManager
implementation that statically invokes the mocks.
Link time switching (Templatized)
Another way to replace dependencies is with a class template. Particularly, turn CameraPowerController
into one.
CameraPowerController.h
template<typename SerialPortManager>
class CameraPowerController
{
public:
CameraPowerController(ProductVariant productVariant)
{
switch (productVariant)
{
case ProductVariant::A:
{
const std::filesystem::path kSerialDevicePathForVariantA{
"/dev/CoolCompanyDevice"};
const auto kBaudRateForVariantA = 9600;
mSerialPortManager = std::make_unique<SerialPortManager>(
kSerialDevicePathForVariantA, kBaudRateForVariantA);
}
return;
case ProductVariant::B:
{
const std::filesystem::path kSerialDevicePathForVariantB{"COM3"};
const auto kBaudRateForVariantB = 115200;
mSerialPortManager = std::make_unique<SerialPortManager>(
kSerialDevicePathForVariantB, kBaudRateForVariantB);
}
return;
default:
throw std::logic_error("Unknown variant");
}
}
void turnOffCamera()
{
mSerialPortManager->asioWrite("OFF");
}
private:
std::unique_ptr<SerialPortManager> mSerialPortManager;
};
CameraPowerController
is not coupled to the implementation of AsioSerialPortManager
but something that behaves exactly
like one. This means we can create variants by using a different type during the CameraPowerController
instantiation.
For the unit tests, we follow a similar approach to the other “link switch” method.
YouTube
This tutorial also exists as a video on YouTube. Check it out and if you enjoyed it don’t forget to comment, like and subscribe! 😊