Steve Love
@IAmSteveLove@mastodon.social
@stevelove.bsky.social
https://moderncsharp.blogspot.com/
Affiliate Link: https://mng.bz/0GEp:
Tell don’t ask
Parameterise from above
Some "below the fold" features
Managing non-trivial dependencies
A word about TDD
A design’s language
Turn heating on at certain times every day if needed
Facility to read the current temperature
via 3rd party API
Facility to activate/deactivate heating system
a different 3rd party API
Probably in a loop:
if start_time < time_now < end_time
read sensor
if heating is off
if temperature < required
turn on heating
else
if temperature > required
turn off heating
Any number of reasons -
haven’t finalized the hardware
haven’t yet decided on a provider/library
developed elsewhere in the company
3rd party haven’t finished it yet
…
How do I design so that the choice between A and B is less significant?
Separate concerns:
Turning the heating on or off
vs.
Deciding when to turn it on or off
Uncertainty hidden by abstraction
class heating_system
{
public:
virtual ~heating_system() noexcept = default;
virtual bool switch_on() = 0;
virtual bool switch_off() = 0;
[[nodiscard]] virtual bool current_status() const noexcept = 0;
// [[nodiscard]] - C++17
};
heating_system(const heating_system &) = default;
heating_system(heating_system &&) = default;
heating_system& operator=(heating_system &&) = default;
heating_system& operator=(const heating_system &) = default;
or 6…
protected:
heating_system() = default;
Fake it 'til you make it!
class fake_heating : public heating::heating_system
{
public:
explicit fake_heating(bool status) noexcept
: active { status } {}
bool switch_on() override { return active = true; }
bool switch_off() override { return active = false; }
[[nodiscard]] bool current_status() const noexcept override
{ return active; }
private:
bool active;
};
class controller
{
public:
explicit controller(heating::heating_system * other,
std::chrono::duration<int> start,
std::chrono::duration<int> end);
void activate() const;
private:
std::chrono::duration<int> time_on;
std::chrono::duration<int> time_off;
heating::heating_system * const heating;
};
void controller::activate() const {
auto now = chrono::system_clock::now();
auto base = chrono::floor<chrono::days>(now); // C++17
if(now > base + time_on && now <= base + time_off)
heating->switch_on();
else
heating->switch_off();
}
tell the controller to activate
vs.
ask the controller if the time is right
TEST(ControllerTest, Outline)
{
auto heater = make_unique<fake_heating>(false);
controller ctrl { heater.get(),
8h+30min, 16h+30min };
ctrl.activate();
ASSERT_TRUE(heater->current_status());
}
activate
asks for current time
raw pointers make life(time) hard
chrono::duration
not really descriptive
using time_of_day = std::chrono::duration<int>;
using wallclock_time = std::chrono::time_point<std::chrono::system_clock>;
class time_window
{
public:
time_window(time_of_day start, time_of_day end);
[[nodiscard]] bool in_window(wallclock_time now) const;
private:
time_of_day start, end;
};
Sometimes, asking is the right thing to do
class controller
{
public:
controller(shared_ptr<heating_system> heater, const time_window & timer);
void activate(wallclock_time now) const;
private:
shared_ptr<heating_system> heater;
time_window timer;
};
auto heater = make_shared<fake_heating>(false);
time_window window { 8h+00min, 18h+00min };
controller ctrl { heater, window };
ctrl.activate(wallclock_time { 10h+11min });
// or chrono::system_clock::now()
ASSERT_TRUE(heater->current_status());
Test for outcome not implementation
controller ctrl { heater, timer };
ctrl.set_required_temp(25);
ctrl.activate(24, wallclock_time { 8h + 15min });
ASSERT_TRUE(heater->current_status());
We just pass in the value to controller
Perhaps there’s a better way…
extern "C" int sensor_read()
class thermostat : public heating_system
{
public:
thermostat(shared_ptr<heating_system> heater,
int target_temp,
function<int()> read);
bool switch_on() override;
bool switch_off() override;
[[nodiscard]]
bool current_status() const noexcept override;
private:
shared_ptr<heating_system> heater;
int target_temp;
function<int()> read_sensor;
};
int hardcoded_sensor() { return 22; }
TEST(ThermostatTests, Outline)
{
auto heater = make_shared<fake_heating>(false);
auto stat = make_shared<thermostat>(
heater, 25, hardcoded_sensor);
stat->switch_on();
ASSERT_TRUE(heater->current_status());
}
extern "C" int sensor_read()
int hardcoded_sensor() { return 22; }
auto heater = make_shared<fake_heating>(false);
auto stat = make_shared<thermostat>(
heater, 25, hardcoded_sensor);
time_window window { 8h+00min, 18h+00min };
controller ctrl { stat, window };
ctrl.activate(wallclock_time { 10h+11min });
ASSERT_TRUE(heater->current_status());
reading the current temperature is encapsulated
⇐
template<std::invocable Function> // concepts - C++20
class timer_activator : public heating_system
{
public:
timer_activator(std::shared_ptr<heating_system> heater,
const time_window & timer,
Function get_now);
bool switch_on() override;
bool switch_off() override;
[[nodiscard]] bool current_status() const noexcept override;
private:
std::shared_ptr<heating_system> heater;
time_window timer;
Function get_now;
};
Replaces controller
altogether
TEST(ActivatorTests, Chain)
{
auto heating = make_shared<fake_heating>(false);
auto get_now = []() { return wallclock_time {8h + 01min }; };
auto read_sensor = []() { return 22; };
time_window window { 8h, 9h };
// CTAD - C++20
auto timer = shared_ptr<heating_system>{ new timer_activator{ heating, window, get_now } };
auto stat = shared_ptr<heating_system>{ new therm_activator{ timer, 25, read_sensor } };
stat->switch_on();
ASSERT_TRUE(heating->current_status());
}
Each part of the chain calls—and depends on—the next
timer
and stat
are also testable independently
How would you add functionality to over-ride the heating?
Something like "Extra Hour"
template<std::invocable Function>
class override_activator : public heating_system
{
public:
override_activator(std::shared_ptr<heating_system> heater, Function get_now);
bool switch_on() override;
bool switch_off() override;
void over_ride(wallclock_time off_time); // <- type-specific
[[nodiscard]] bool current_status() const noexcept override;
// ...
};
auto get_now = []() { return wallclock_time { 8h + 01min }; };
auto over = shared_ptr<override_activator>{
new override_activator{ heating, get_now } };
// ...
// method specific to override_activator
over->over_ride(wallclock_time { 9h+3min + 1h });
Fails to compile
override_activator
requires template arguments
Sprinkle a little magic template dust…
template<template<class> typename T,
typename B,
std::invocable C>
auto make_activator(std::shared_ptr<B> h, const C & call)
{
return std::make_shared<T<C>>(std::move(h), call);
}
…to make using it a little easier
auto over = make_activator<override_activator>(heating, get_now);
// method specific to override_activator
over->over_ride(wallclock_time { 9h+3min + 1h });
Let uncertainty inform design, not block it
Own your types and give them names
Tell objects what to do rather than ask them how
Pass dependencies as arguments instead of wiring them in
Extensible design is more than OO
… and vice-versa!
Steve Love
@IAmSteveLove@mastodon.social
@stevelove.bsky.social
https://moderncsharp.blogspot.com/
https://www.linkedin.com/in/steve-love-1198994
and even
@IAmSteveLove
Performance
std::function
vs. templated lambda
std::function
class thermostat : public heating_system
{
public:
thermostat(shared_ptr<heating_system> heater,
int target_temp,
function<int()> read);
bool switch_on() override;
bool switch_off() override;
[[nodiscard]]
bool current_status() const noexcept override;
private:
shared_ptr<heating_system> heater;
int target_temp;
function<int()> read_sensor;
};
bool thermostat::switch_on() {
if(read_sensor() < target_temp)
return heater->switch_on();
else
return heater->switch_off();
}
std::invocable
template<std::invocable Function>
class therm_activator : public heating_system
{
public:
therm_activator(std::shared_ptr<heating_system> heater,
int target,
Function read_temp);
bool switch_on() override;
bool switch_off() override;
[[nodiscard]] bool current_status() const noexcept override;
private:
std::shared_ptr<heating_system> heater;
int required;
Function read_sensor;
};
template<std::invocable Function>
bool therm_activator<Function>::switch_on() {
if(read_sensor() < required)
return heater->switch_on();
else
return heater->switch_off();
}
void BM_ThermostatClass(benchmark::State & state)
{
auto heating = make_shared<fake_heating>(false);
auto stat = thermostat {
heating,
23,
[]() { return 20; } };
for(auto _ : state)
{
for(auto i = 0ll; i != capacity; ++i)
benchmark::DoNotOptimize(stat.switch_on());
}
}
void BM_ThermostatTemplate(benchmark::State & state)
{
auto heating = make_shared<fake_heating>(false);
auto stat = heating::therm_activator{
heating,
23,
[]() { return 20; } };
for(auto _ : state)
{
for(auto i = 0ll; i != capacity; ++i)
benchmark::DoNotOptimize(stat.switch_on());
}
}
capacity = 250LL
g++ (MinGW-W64 x86_64-ucrt-posix-seh, built by Brecht Sanders, r1) 14.1.0
2024-06-01T14:18:11+01:00
Running [...]
Run on (20 X 2995 MHz CPU s)
CPU Caches:
L1 Data 48 KiB (x10)
L1 Instruction 32 KiB (x10)
L2 Unified 1280 KiB (x10)
L3 Unified 24576 KiB (x1)
----------------------------------------------------------------
Benchmark Time CPU Iterations
----------------------------------------------------------------
BM_ThermostatClass 469 ns 345 ns 2036364
BM_ThermostatTemplate 211 ns 135 ns 4977778
capacity = 2500LL
----------------------------------------------------------------
Benchmark Time CPU Iterations
----------------------------------------------------------------
BM_ThermostatClass 4701 ns 1782 ns 640000
BM_ThermostatTemplate 2027 ns 725 ns 1120000
The trouble with RVO…
class my_activator : public heating_system
{
public:
explicit my_activator(heating_system & other)
: ref { other } { }
[[nodiscard]] bool current_status() const override
{ return ref.current_status(); }
// ...
private:
heating_system & ref;
};
void shared_scope()
{
fake f{ };
my_activator h{ f };
cout << h.current_status();
// all fine - both f and h go
// out of scope
}
my_activator make_activator(/*...*/)
{
fake f{ };
return my_activator{ f };
}
void run_service()
{
auto activator = make_activator(/*...*/);
cout << activator.current_status(); // BANG!
}
class heating_system
{
public:
virtual ~heating_system() noexcept = default;
heating_system() = default;
heating_system(const heating_system &) = delete;
heating_system(heating_system &&) = delete;
heating_system & operator=(const heating_system &) = delete;
heating_system && operator=(heating_system &&) = delete;
[[nodiscard]] virtual bool current_status() const = 0;
};