In one of the previous articles on binary sizes, we discussed how making a class polymorphic by using the virtual
keyword affects the binary size. Turning a function into virtual
has a substantial effect on the binary size, but adding more and more virtual
methods to a class that already has at least one virtual
function does not change that much.
To have an elaborate example I went to the publicly available code examples of C++ Software Design by Klaus Iglberger. As I explained here, it’s one of the best books I read in 2022. If you are interested in software design and C++, in my opinion, it’s a must-read.
In the book, you can find different implementations of various design patterns. All the discussed design patterns are first presented through their classic implementation, usually based on polymorphism and then modern alternatives are also explained.
Sometimes, the modern implementations offer the same functionality, sometimes they are restricted for compile-time needs, but that’s often enough for the needs.
In this and the next article, I want to go through two implementations of two design patterns and focus on their effects on binary sizes. Today, it’s the decorator pattern on the plate.
The decorator pattern
Let’s start by quickly recap on what is the decorator design pattern. In the Gang of Four book, it was listed as one of the structural design patterns. Some also refer to it as the wrapper pattern. Both names are good, as this pattern is about adding new behaviour to objects in a non-intrusive way. The decorator pattern places these objects into special wrapper objects responsible for attaching the new behaviour.
Imagine that you have an item which has both a name and a price and some other attributes. However, having a price is not enough. Depending on where you want to sell it, you have to apply different taxes. Not to mention that you might also want to apply some discounts.
Having the logic inside the class is not a great idea, and creating inheritance hierarchies does not scale as you add more and more taxes and discounts.
The decorator pattern provides a scalable solution.
The classic solution
Klaus provided 3 solutions in his book. The first one is a classical solution based on runtime polymorphism. As I cannot compile templates with floating-point non-type arguments, I modified his example a bit.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// taxed.h
#pragma once
#include "item.h"
#include <utility>
class Taxed
{
public:
Taxed( double taxRate, Item item )
: item_( std::move(item) )
, factor_( 1.0 + taxRate )
{}
Money price() const
{
return item_.price() * factor_;
}
private:
Item item_;
double factor_;
};
// money.h
#pragma once
#include <cmath>
#include <concepts>
#include <cstdint>
#include <ostream>
struct Money
{
uint64_t value{};
};
template< typename T >
requires std::is_arithmetic_v<T>
Money operator*( Money money, T factor )
{
return Money{ static_cast<uint64_t>( money.value * factor ) };
}
constexpr Money operator+( Money lhs, Money rhs ) noexcept
{
return Money{ lhs.value + rhs.value };
}
std::ostream& operator<<( std::ostream& os, Money money )
{
return os << money.value;
}
// item.h
#pragma once
#include "money.h"
#include <memory>
#include <utility>
class Item
{
public:
template< typename T >
Item( T item )
: pimpl_( std::make_unique<Model<T>>( std::move(item) ) )
{}
Item( Item const& item ) : pimpl_( item.pimpl_->clone() ) {}
Item& operator=( Item const& item )
{
pimpl_ = item.pimpl_->clone();
return *this;
}
~Item() = default;
Item( Item&& ) = default;
Item& operator=( Item&& item ) = default;
Money price() const { return pimpl_->price(); }
private:
struct Concept
{
virtual ~Concept() = default;
virtual Money price() const = 0;
virtual std::unique_ptr<Concept> clone() const = 0;
};
template< typename T >
struct Model : public Concept
{
explicit Model( T const& item ) : item_( item ) {}
explicit Model( T&& item ) : item_( std::move(item) ) {}
Money price() const override
{
return item_.price();
}
std::unique_ptr<Concept> clone() const override
{
return std::make_unique<Model<T>>(*this);
}
T item_;
};
std::unique_ptr<Concept> pimpl_;
};
// discounted,h
#pragma once
#include "item.h"
#include <utility>
class Discounted
{
public:
Discounted( double discount, Item item )
: item_( std::move(item) )
, factor_( 1.0 - discount )
{}
Money price() const
{
return item_.price() * factor_;
}
private:
Item item_;
double factor_;
};
// cpp_book.h
#pragma once
#include "money.h"
#include <string>
#include <utility>
class CppBook
{
public:
CppBook( std::string name, Money price )
: name_{ std::move(name) }
, price_{ price }
{}
std::string const& name() const { return name_; }
Money price() const { return price_; }
private:
std::string name_;
Money price_;
};
// conference_ticket.h
#pragma once
#include "money.h"
#include <string>
#include <utility>
class ConferenceTicket
{
public:
ConferenceTicket( std::string name, Money price )
: name_{ std::move(name) }
, price_{ price }
{}
std::string const& name() const { return name_; }
Money price() const { return price_; }
private:
std::string name_;
Money price_;
};
// main.cpp
#include "conference_ticket.h"
#include "cpp_book.h"
#include "discounted.h"
#include "taxed.h"
#include <cstdlib>
int main()
{
// 20% discount, 15% tax: (499*0.8)*1.15 = 459.08
Item item( Taxed( 0.15, Discounted(0.2, ConferenceTicket{ "Core C++", Money{499} } ) ) );
Item item2( Taxed( 0.17, Discounted(0.2, ConferenceTicket{ "Core C++", Money{499} } ) ) );
Item item3( Taxed( 0.18, Discounted(0.21, CppBook{ "Software Design", Money{499} } ) ) );
Money const totalPrice = item.price();
Money const totalPrice2 = item2.price();
Money const totalPrice3 = item3.price();
// ...
return EXIT_SUCCESS;
}
The modern solution
The second solution is a modern value-semantics-based one, providing a solution for compile-time decoration.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// taxed.h
#pragma once
#include "money.h"
#include "priced_item.h"
#include <utility>
template< int taxRate, PricedItem Item >
class Taxed : private Item // Using inheritance
{
public:
template< typename... Args >
explicit Taxed( Args&&... args )
: Item{ std::forward<Args>(args)... }
{}
Money price() const {
return Item::price() * ( 1.0 + (taxRate/100) );
}
};
// priced_item.h
#pragma once
#include "money.h"
template< typename T >
concept PricedItem =
requires ( T item ) {
{ item.price() } -> std::same_as<Money>;
};
// money.h
#pragma once
#include <cmath>
#include <concepts>
#include <cstdint>
#include <ostream>
struct Money
{
uint64_t value{};
};
template< typename T >
requires std::is_arithmetic_v<T>
Money operator*( Money money, T factor )
{
return Money{ static_cast<uint64_t>( money.value * factor ) };
}
constexpr Money operator+( Money lhs, Money rhs ) noexcept
{
return Money{ lhs.value + rhs.value };
}
std::ostream& operator<<( std::ostream& os, Money money )
{
return os << money.value;
}
// discounted.h
#pragma once
#include "money.h"
#include "priced_item.h"
#include <utility>
template< int discount, PricedItem Item >
class Discounted // Using composition
{
public:
template< typename... Args >
explicit Discounted( Args&&... args )
: item_{ std::forward<Args>(args)... }
{}
Money price() const {
return item_.price() * ( 1.0 - (discount/100) );
}
private:
Item item_;
};
// cpp_book.h
#pragma once
#include "money.h"
#include <string>
#include <utility>
class CppBook
{
public:
CppBook( std::string name, Money price )
: name_{ std::move(name) }
, price_{ price }
{}
std::string const& name() const { return name_; }
Money price() const { return price_; }
private:
std::string name_;
Money price_;
};
// main.cpp
#include "conference_ticket.h"
#include "cpp_book.h"
#include "discounted.h"
#include "taxed.h"
#include <cstdlib>
int main()
{
// 20% discount, 15% tax: (499*0.8)*1.15 = 459.08
Taxed<15,Discounted<20,ConferenceTicket>> item{ "Core C++", Money{499} };
Taxed<16,Discounted<21,ConferenceTicket>> item2{ "Core C++", Money{499} };
Taxed<17,Discounted<22,CppBook>> item3{ "Core C++", Money{499} };
Money const totalPrice = item.price(); // Results in 459.08
Money const totalPrice2 = item2.price();
Money const totalPrice3 = item3.price();
// ...
return EXIT_SUCCESS;
}
Comparing the solutions
As Klaus explains in the book, the compile-time approach provides a faster run-time performance by a factor of 10.
That’s a huge difference!
It’s not very surprising though as there is no virtual dispatching, no run-time type resolution, and everything is known at compile-time. If that fits your needs, you should seriously consider the compile-time version.
But what about the executable sizes?
As we saw, declaring classes with virtual
destructors has a price. The polymorphic solution is not just slower, but it also generates a bigger executable.
Version | Binary size at -O0 | Binary size at -O3 | Binary size at -Os |
---|---|---|---|
classical decorator | 76.1K | 35.5K | 36.1K |
modern decorator | 39.9K | 33.6K | 34.3K |
But…
There is a huge but here. The scope of the example is very limited in terms of different items. Meaning that while the size of the virtual
solution is slightly bigger, we have to ask ourselves the question of how would it scale.
The runtime solution requires creating a new subclass for each different kind of item and that costs some bytes. We cannot talk about exact sizes as it depends on so many things. But in the -Os/-O3
optimized version, a new JavaBook
class (based on CppBook
) added an extra 200 bytes. But it could easily be much more depending on the class itself.
On the other hand, the compile-time solution uses templates both for taking a discount, for a tax and for taking the item too. Each different invocation will generate a new class which also adds up to the size.
Based on what I can see, if you deal with the same few types of items, but with a variety of taxes and discounts, the virtual solution will scale better.
But if there are also lots of new item types needed, the compile-time solution is not that bad not even in terms of executable size. Based on my experiments, both solutions scaled similarly when I added new types of items.
But you have to measure so that you know for sure. And also let’s not forget that a compile-time decorated is not always an option as you might need that runtime flexibility.
Conclusion
Today, we compared classic and modern implementations of the decorator patterns in terms of binary sizes. The modern version of a decorator pattern is both faster and smaller than the classic implementation. But you have to keep in mind, that the virtual
solution might scale better and that you might need the run-time flexibility of the classic pattern.
Next week, we’ll look into the observer design pattern!
Connect deeper
If you liked this article, please
- hit on the like button,
- subscribe to my newsletter
- and let’s connect on Twitter!