3

The decorator pattern and binary sizes

 1 year ago
source link: https://www.sandordargo.com/blog/2023/03/08/binary-sizes-and-decorator-pattern
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

The decorator pattern and binary sizes

Sandor Dargo 14 hours ago2023-03-08T00:00:00+01:00
11 min

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.

// 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.

// 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.

VersionBinary size at -O0Binary size at -O3Binary size at -Os
classical decorator76.1K35.5K36.1K
modern decorator39.9K33.6K34.3K

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


Recommend

  • 57
    • www.tuicool.com 6 years ago
    • Cache

    Decorator Design Pattern in Java

    Let's imagine that we want to create a game, and, for our superhero, we want to add a new gadget to every level of the game. Each gadget should be selected by the player. How can we do that? One way to...

  • 6

    1. fetch() is good, but you want better fetch() API lets you perform network requests in web applications. fetch() usage is pretty straightforward: invoke fetch('/movies.json') to star...

  • 3

    There are two patterns in PHP that are very similar; The Decorator Pattern and The Proxy Pattern. Because they are so similar, you can quickly mistake one for the other. Does that matter? Maybe not, but I think it's good to know the differenc...

  • 3
    • www.sandordargo.com 1 year ago
    • Cache

    Object initialization and binary sizes

    Object initialization and binary sizes Sandor Dargo 22 hours ago2023-01-18T00:00:00+01:00Let’s have this piece of code that generates a big binary.#include <array> struct Node {...

  • 8
    • www.sandordargo.com 1 year ago
    • Cache

    Special functions and binary sizes

    Special functions and binary sizes Sandor Dargo on Jan 312023-02-01T00:00:00+01:00These months, I try to better understand how our code affects binary sizes. Last week, we had a look into storage durations a...

  • 9
    • www.sandordargo.com 1 year ago
    • Cache

    Virtual functions and binary sizes

    Virtual functions and binary sizes Sandor Dargo 18 hours ago2023-02-08T00:00:00+01:00In the previous article of this series on binary sizes, we discussed how special functions’ implementations - or their lac...

  • 7
    • www.sandordargo.com 1 year ago
    • Cache

    Binary sizes and RTTI

    Sandor Dargo on Feb 282023-03-01T00:00:00+01:00What is RTTI? What does it have to do with the size of your executables?Let’s start with answering the first one.RTTI stands for run-time type informa...

  • 6
    • www.sandordargo.com 1 year ago
    • Cache

    The observer pattern and binary sizes

    The observer pattern and binary sizes Sandor Dargo 10 hours ago2023-03-15T00:00:00+01:00 11 minIn the previous article on binary sizes, we discussed how the decorator pattern’s classic and modern...

  • 4

    Binary sizes and passing functions to functions Sandor Dargo 1 day ago2023-04-05T00:00:00+02:00 10 minSo why templates are interesting for our series on binary sizes? It’s because as mentioned, th...

  • 5
    • www.sandordargo.com 1 year ago
    • Cache

    Binary sizes and compiler flags

    Binary sizes and compiler flags Sandor Dargo on Jul 182023-07-19T00:00:00+02:00During the last few months, we discussed a lot about how to write code to limit the size of our binary.

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK