Design of C++ classes
published:
Motivation
Inspired by
How to write the perfect function (for humans who still write code),
I would like to share how I write and design classes in C++.
A good C++ class
- shows a clear interface in the header
- is testable
- and compiles fast
Let's implement a simple class to do health checks using some protocol using a tcp connection.
To do so we are going to use asio, a cross platform networking library.
Like many powerful C++ libraries it make heavy use of templates, which resolves in slow compilation.
Let's to a straight forward solution and then refactor it.
Classic Approach
The header, that shows the interface and capabilities of our class.
#ifndef UUID_793DB3EF_DAD5_46CA_9E1F_0F95F0BE6EF3
#define UUID_793DB3EF_DAD5_46CA_9E1F_0F95F0BE6EF3
#include <asio/ip/tcp.hpp>
#include <string_view>
class service_health_checker {
public:
service_health_checker(std::string_view address);
virtual ~service_health_checker() = default;
// `virtual` for testability
virtual bool is_healthy();
private:
std::unique_ptr<asio::ip::tcp::socket> connect();
std::string make_request();
std::string send_request(std::string_view request);
bool parse_response(std::string_view response);
asio::io_context io;
std::unique_ptr<asio::ip::tcp::socket> client;
};
#endif
The Implementation file
#include "service_health_checker.hpp"
std::unique_ptr<asio::ip::tcp::socket>
make_connected_tcp_socket(asio::io_context &io, std::string_view address) {
// unimplemented, for readability
// here happens:
// - resolve address
// - create socket
// - connect
}
service_health_checker::service_health_checker(std::string_view address)
: client{make_connected_tcp_socket(io, address)} {}
bool service_health_checker::is_healthy() {
auto request = make_request();
auto response = send_request(request);
return parse_response(response);
}
std::unique_ptr<asio::ip::tcp::socket> service_health_checker::connect() {
// unimplemented, for readability
}
std::string service_health_checker::make_request() {
// unimplemented, for readability
}
bool service_health_checker::parse_response(std::string_view body) {
// unimplemented, for readability
}
Three issues with the classic approach
The first issue I want to focus on is the compilation-time!
Due to the #include <asio.hpp> our compilation-time raises by several seconds, for any file that includes service_health_checker.hpp.
Let's not forget that these includes are transitive. Each time we include service_health_checker.hpp in another header asio gets included.
After a while and many more features the project compilations times raise to more than thirty seconds (or worse).
My second issue with the classic approach is that things are getting worse when we acknowledge that asio is only used internally. Inside the class!
Its types are not part of the signatures of the public functions.
So let's assume service_health_checker.hpp becomes part of a library. In that library that header gets exposed as a feature.
Now each executable using that library has to know and provide the dependency asio, although it's an internal dependency. A implementation-detail. Terrible!
My third issue with the classic approach is that we are exposing internal logic:
std::unique_ptr<asio::ip::tcp::socket> connect();
std::string make_request();
std::string send_request(std::string_view request);
bool parse_response(std::string_view response);
asio::io_context io;
std::unique_ptr<asio::ip::tcp::socket> client;
We can do better!
A Better Approach
A classic approach, that I don't like, is the Pimpl (Pointer To Implementation) solution.
We hide the actual implementation behind a pointer:
#include <memory>
#include <string_view>
class service_health_checker {
public:
service_health_checker(std::string_view address);
virtual ~service_health_checker();
virtual bool is_healthy();
private:
class service_health_checker_impl;
std::unique_ptr<service_health_checker_impl> pimpl;
};
The interface of service_health_checker is now much smaller an cleaner.
No heavy dependencies to asio are now exposed in our header.
The internal variables and methods are gone too.
All we have is a single pointer to the actual implementation.
Let's look at the implementation file:
#include "service_health_checker.hpp"
#include "service_health_checker_impl.hpp"
service_health_checker::service_health_checker(std::string_view address)
: pimpl{std::make_unique<service_health_checker_impl>(address)} {}
bool service_health_checker::is_healthy() {
// delegate all calls
return pimpl->is_healthy();
}
We include the actual implementation in the service_health_checker.cpp file, unlike in the classic approach, where we include it in the header file service_health_checker.hpp.
Then we delegate all calls to the implementation.
The service_health_checker_impl.hpp now contains the content of the header from the classic approach.
The only difference is, that service_health_checker_impl it derives from service_health_checker.
#include "service_health_checker.hpp"
#include <asio/ip/tcp.hpp>
#include <string_view>
class service_health_checker_impl : service_health_checker {
public:
service_health_checker_impl(std::string_view address);
~service_health_checker_impl();
bool is_healthy() override;
private:
std::unique_ptr<asio::ip::tcp::socket> connect();
std::string make_request();
std::string send_request(std::string_view request);
bool parse_response(std::string_view response);
asio::io_context io;
std::unique_ptr<asio::ip::tcp::socket> client;
};
There is another file service_health_checker_impl.cpp which I'm gonna skip because it's just the contents of service_health_checker.cpp from the classic approach
but instead of service_health_checker it's implemented for service_health_checker_impl.
With the Pimpl approach we solved all three issues we had with the classic approach.
- We improved compilation speed. Unless we change the public interface of
service_health_checkerwe will only recompileservice_health_checker.cppandservice_health_checker_impl.cppon changes. - We don't expose
#include <asio.hpp>to othercppfiles usingservice_health_checker.hpp. - We have a clean interface in the
service_health_checker.hpp, not exposing any internal implementation details.
However now we got four files for a single class!
We can do even better!
The best approach
We declare a interface in the header and add a factory function.
#include <memory>
#include <string_view>
class service_health_checker {
public:
static std::unique_ptr<service_health_checker>
make(std::string_view address);
virtual ~service_health_checker() = default;
virtual bool is_healthy() = 0;
};
The constructor got replaced by a factory function make.
There are no member variables and is_healthy became a pure virtual function.
Let's look at the implementation file:
#include "service_health_checker.hpp"
#include <asio/ip/tcp.hpp>
std::unique_ptr<asio::ip::tcp::socket>
make_connected_tcp_socket(asio::io_context &io, std::string_view address) {
// unimplemented, for readability
// here happens:
// - resolve address
// - create socket
// - connect
}
struct service_health_checker_impl : service_health_checker {
asio::io_context io;
std::unique_ptr<asio::ip::tcp::socket> client;
service_health_checker_impl::service_health_checker(std::string_view address)
: client{make_connected_tcp_socket(io, address)} {}
bool is_healthy() override {
auto request = make_request();
auto response = send_request(request);
return parse_response(response);
}
std::unique_ptr<asio::ip::tcp::socket> connect() {
// unimplemented, for readability
}
std::string make_request() {
// unimplemented, for readability
}
bool parse_response(std::string_view body) {
// unimplemented, for readability
}
};
std::unique_ptr<service_health_checker>
service_health_checker::make(std::string_view address) {
return std::make_unique<service_health_checker_impl>(address);
}
First I'm deriving the public interface struct service_health_checker_impl : service_health_checker.
Then I'm declaring the variables. Due to the implementation happening in the cpp file, I'm not even considering using public/protected/private.
I can go straight to business. When I need another method I just write it, no jumping between header/source files.
For an even cleaner implementation one can wrap the service_health_checker_impl in an anonymous namespace.
Instancing happens at the bottom in a static function.
Unlike in the classic and the Pimpl we can't do a direct allocation.
// classic and `Pimpl` approach
auto checker = std::make_unique<service_health_checker>("example.com");
// factory function
auto checker = service_health_checker::make("example.com");
Using this approach we successfully
- improve compilation-time. When we change logic of
service_health_checkeronlyservice_health_checker.cpphas to recompile. - hide dependencies. In our example
#include <asio.hpp>happens in the implementation file, not in the header file. - provide a clean header file, exposing exclusively methods and function interesting for the consumer.
The main other side-effect of this approach are that I can much easier reason about the code, because everything happens in a single file. This makes searching and reading much easier.
A word about factory functions
Factory functions have some nice side-effects. For example, let's assume we later want to refactor the service_health_checker
to be asynchronous.
To do so the class internal uses a shared_ptr to itself in the callbacks.
This gets accomplished by using std::enable_shared_from_this.
Now we can enforce and ensure that the class get's instanced using the factory-function:
std::shared_ptr<service_health_checker>
service_health_checker::make(std::string_view address) {
return std::make_shared<service_health_checker_impl>(address);
}
The C++ type system handles the rest of the refactoring.
Another use-case could be different instances dependent on parameters of flags. For example:
std::unique_ptr<service_health_checker>
service_health_checker::make(std::string_view address) {
if (address.rfind("tcp://", 0) == 0) {
return std::make_unique<service_health_checker_tcp>(address);
}
return std::make_unique<service_health_checker_generic>(address);
}Conclusion
In conclusion I like to design my business logic as a pure virtual class in a header. Then implement it with all functions and methods in the implementation file. Instancing happens using factory functions.
This results in:
- a clear interface for users of the class
- fast compilation
- better readability and maintainability of the code