trainings/CreatingReliableSoftwareCpp/Presentation/decorator.md

20 KiB

Decorator

The last design pattern that I mention today is a decorator. Before we talk about it, let's extend Cargo class and handle a few new functionalities:

  • The item may be damaged during a time (we have salty air) so it can lose his value during travelling
  • The fruit may rotten, so we also want to lower its price every day
  • The alcohol price should be based on it's power
  • The value of item should be higher based on its type (common is cheapest but legendary has a big value)

New implementation

Let's start with a new implementation of class Fruit. Now we inherit from TimeObserver to count days. We also take in account that the price will be lowered every day.

struct Fruit : public Cargo, public TimeObserver {
    static constexpr int MAX_TIME = 20;
    static constexpr int BASE_PRICE = 10;
    int rottenCounter;
    Time* time;

    Fruit(Time* time, size_t amount, int rottenCounter)
        : Cargo(amount), time(time), rottenCounter(rottenCounter) {
        time->attach(this);
    }

    // Rule of 5
    ~Fruit() {
        time->detach(this);
    }

    size_t getPrice() const override {
        return (rottenCounter * BASE_PRICE) / MAX_TIME;
    }

    const std::string& name() const override {
        static const std::string name = "Banana";
        return name;
    }

    void nextDay() override {
        rottenCounter = std::max(0, rottenCounter - 1);
    }
};

struct Item : public Cargo, public TimeObserver {
    enum class Type { Common = 100,
                      Rare = 300,
                      Epic = 1000,
                      Legendary = 2500 };
    static constexpr int MAX_DURABILITY = 100;
    Time* time;
    Type type;
    int durability{MAX_DURABILITY};

    Item(Time* time, size_t amount, Type type)
        : Cargo(amount), time(time), type(type) {
        time->attach(this);
    }

    // Rule of 5
    ~Item() {
        time->detach(this);
    }

    size_t getPrice() const override {
        return (durability * static_cast<int>(type)) / MAX_DURABILITY;
    }

    const std::string& name() const override {
        static const std::string name = "Item";
        return name;
    }

    void nextDay() override {
        durability = std::max(0, durability - 1);
    }
};

Duplication of code

At the end, we also implement a Alcohol class. As you can see, we duplicate behavior few times. Item and Fruit may lose value each day. Price of Item and Alcohol depend on it's type etc…

struct Alcohol : public Cargo {
    enum class Type { White = 100,
                      Spiced = 200,
                      Dark = 300,
                      Seasoned = 500 };
    static constexpr int MAX_POWER = 96;
    int power;
    Type type;

    Alcohol(size_t amount, int power, Type type)
        : Cargo(amount), power(power), type(type) {}

    size_t getPrice() const override {
        return (power * static_cast<int>(type)) / MAX_POWER;
    }

    const std::string& name() const override {
        static const std::string name = "Rum";
        return name;
    }
};

The following code works as we want to. However, whenever we want to change a logic, for instance instead of decrement the counter every day, we want to subtract durability and rottenCounter every second day. We need to do this in two places. And when we will add more cargo, we will need to do this everywhere. This is not a good example of easy to maintenance code.

int main() {
    Time time;
    std::unique_ptr<Cargo> cargo = std::make_unique<Item>(&time, 10, Item::Type::Epic);

    for (int i = 1; i <= 15; ++i) {
        std::cout << "DAY: " << i << " | Name: " << cargo->name() << " | Price: " << cargo->getPrice() << '\n';
        ++time;
    }
}
DAY: 1 | Name: Item | Price: 1000
DAY: 2 | Name: Item | Price: 990
DAY: 3 | Name: Item | Price: 980
DAY: 4 | Name: Item | Price: 970
DAY: 5 | Name: Item | Price: 960
DAY: 6 | Name: Item | Price: 950
DAY: 7 | Name: Item | Price: 940
DAY: 8 | Name: Item | Price: 930
DAY: 9 | Name: Item | Price: 920
DAY: 10 | Name: Item | Price: 910
DAY: 11 | Name: Item | Price: 900
DAY: 12 | Name: Item | Price: 890
DAY: 13 | Name: Item | Price: 880
DAY: 14 | Name: Item | Price: 870
DAY: 15 | Name: Item | Price: 860

Drawback

According to one of SOLID rule Open-close, we should write an easy to extend code, which don't require from us modification of already implemented code. Unfortunately, we need to modify each class to append a new logic, like: decrement durability each day or get price based on durability or type. What's more, we don't want to simulate rotten of fruit or lose durability by items that are stored in shops. So we need to add some bool flag like isInShop to ignore time elapsing. When the code grows and grows, we need to add more such flags and duplicate more code. This is definitely not the way we should develop a code. A better solution is to make a Fruit or Item class unaware of time elapsing. We can also make class item and Alcohol unaware of different types, and it's prices. This will allow to decouple the code, and make it easier to extend.


Decorator

Instead of implement the whole logic to every class, and then make some bool flags to omit a few of them. We can use a decorator pattern. Let's start from the interface. The DecoratedCargo inherit form Cargo class and take as an argument unique_ptr for class Cargo. This interface has also two protected function allowing to get underlying cargo.

class DecoratedCargo : public Cargo {
public:
    explicit DecoratedCargo(std::unique_ptr<Cargo>&& cargo)
        : _cargo(std::move(cargo)) {
        assert(_cargo);
    }

protected:
    Cargo& cargo() { return *_cargo; }
    const Cargo& cargo() const { return *_cargo; }

private:
    std::unique_ptr<Cargo> _cargo;
};

Vulnerable

A Vulnerable class inherit from TimeObserver to simulate time elapsing and from DecoratedCargo. When we calculate the price, it will calculate percentage value based on durability and maxDurability. Price can be manipulated by other decorator, so we call getPrice.

class Vulnerable : public DecoratedCargo, public TimeObserver {
public:
    Vulnerable(std::unique_ptr<Cargo>&& cargo, Time* time, int durability, int maxDurability)
        : DecoratedCargo(std::move(cargo)), _time(time), _durability(durability), _maxDurability(maxDurability) {
        assert(_time);
        time->attach(this);
    }

    // Rule of 5
    ~Vulnerable() {
        _time->detach(this);
    }

    void nextDay() override {
        _durability = std::max(0, _durability - 1);
    }

    size_t getPrice() const override {
        return (_durability * cargo().getPrice()) / _maxDurability;
    }

    const std::string& name() const override {
        return cargo().name();
    }

private:
    Time* _time;
    int _durability;
    int _maxDurability;
};

Valuable

A Valuable class is a template class that take enum as a template argument. It will be used later when we calculate a price. We will treat it as a multiplier for already calculated price.

template <typename ValueType>
class Valuable : public DecoratedCargo {
public:
    Valuable(std::unique_ptr<Cargo>&& cargo, ValueType value)
        : DecoratedCargo(std::move(cargo)), _value(value) {
    }

    size_t getPrice() const override {
        return cargo().getPrice() * static_cast<int>(_value);
    }

    const std::string& name() const override {
        return cargo().name();
    }

private:
    ValueType _value;
};

Type

Because we want to make Item and Alcohol and any other type unaware about its type. We move it to the separate files. This allows us to develop new functionality without modification of existing code. For instance, when we decided later that we want to add also a type for Fruit we will create a new file and that's all! We don't need to recompile anything, and also don't need to modify anything. So even if Fruit is in a separate repository, and we can't modify it. We still can add new behavior.

enum class ItemType { Common = 1,
                      Rare = 3,
                      Epic = 10,
                      Legendary = 25 };

enum class AlcoholType { White = 1,
                         Spiced = 2,
                         Dark = 3,
                         Seasoned = 5 };

Usage

Now we can concatenate any decorator with a Cargo type, so we can add new functionality in fly.

int main() {
    Time time;
    std::unique_ptr<Cargo> cargo = std::make_unique<Vulnerable>(
        std::make_unique<Valuable<ItemType>>(
            std::make_unique<Item>(10), ItemType::Epic),
        &time, 100, 100);

    for (int i = 1; i <= 15; ++i) {
        std::cout << "DAY: " << i << " | Name: " << cargo->name() << " | Price: " << cargo->getPrice() << '\n';
        ++time;
    }
}
DAY: 1 | Name: Item | Price: 1000
DAY: 2 | Name: Item | Price: 990
DAY: 3 | Name: Item | Price: 980
DAY: 4 | Name: Item | Price: 970
DAY: 5 | Name: Item | Price: 960
DAY: 6 | Name: Item | Price: 950
DAY: 7 | Name: Item | Price: 940
DAY: 8 | Name: Item | Price: 930
DAY: 9 | Name: Item | Price: 920
DAY: 10 | Name: Item | Price: 910
DAY: 11 | Name: Item | Price: 900
DAY: 12 | Name: Item | Price: 890
DAY: 13 | Name: Item | Price: 880
DAY: 14 | Name: Item | Price: 870
DAY: 15 | Name: Item | Price: 860

Store

If cargo is inside the store, we just don't add a Vulnerable decorator!

Time time;
std::unique_ptr<Cargo> cargo = std::make_unique<Vulnerable>(
    std::make_unique<Valuable<ItemType>>(
        std::make_unique<Item>(10), ItemType::Epic),
    &time, 100, 100);
std::unique_ptr<Cargo> cargo2 =
    std::make_unique<Valuable<ItemType>>(
        std::make_unique<Item>(10), ItemType::Epic);

for (int i = 1; i <= 15; ++i) {
    std::cout << "DAY: " << i << " | Name: " << cargo->name() << " | Price: " << cargo->getPrice();
    std::cout << " |-| Name: " << cargo2->name() << " | Price: " << cargo2->getPrice() << '\n';
    ++time;
}
DAY: 1 | Name: Item | Price: 1000 |-| Name: Item | Price: 1000
DAY: 2 | Name: Item | Price: 990 |-| Name: Item | Price: 1000
DAY: 3 | Name: Item | Price: 980 |-| Name: Item | Price: 1000
DAY: 4 | Name: Item | Price: 970 |-| Name: Item | Price: 1000
DAY: 5 | Name: Item | Price: 960 |-| Name: Item | Price: 1000
DAY: 6 | Name: Item | Price: 950 |-| Name: Item | Price: 1000
DAY: 7 | Name: Item | Price: 940 |-| Name: Item | Price: 1000
DAY: 8 | Name: Item | Price: 930 |-| Name: Item | Price: 1000
DAY: 9 | Name: Item | Price: 920 |-| Name: Item | Price: 1000
DAY: 10 | Name: Item | Price: 910 |-| Name: Item | Price: 1000
DAY: 11 | Name: Item | Price: 900 |-| Name: Item | Price: 1000
DAY: 12 | Name: Item | Price: 890 |-| Name: Item | Price: 1000
DAY: 13 | Name: Item | Price: 880 |-| Name: Item | Price: 1000
DAY: 14 | Name: Item | Price: 870 |-| Name: Item | Price: 1000
DAY: 15 | Name: Item | Price: 860 |-| Name: Item | Price: 1000

Decorator

The decorator pattern can be used to extend (decorate) the functionality of a certain object statically, or in some cases at run-time, independently of other instances of the same class, provided some groundwork is done at design time. This is achieved by designing a new Decorator class that wraps the original class.

Decorator UML ___

Decorator also helps with Dependency inversion. Because decorator classes are in low-lvl, so we can easily add a new decorator without changing anything in higher layers.

Decorator Cargo UML ___

Drawbacks

Decorator is powerfull desing pattenr, but as everything it also has some drawbacks.

  • The main problem is that we need to use plenty of `unique_ptr` this will use more memory and also will be slower, because we need to call a few virtual functions.
  • If we add to many decorators, the code became hard to read
  • Debugging code containing decorators also may be more difficult, because we need to jump between a few of the same methods (but located in different decorators) before we reach the final result. So find a bug will be harder
  • If the result of one decorator has an impact on another, we may cause a hard to spot bug when we swap the order. So we shouldn't create a decorator, which should be stored in order, because a risk of concatenated them wrong is high.

Different approach

As one popular proverb says: "Time is money". And if our code must be as fast as possible, and we can't afford a price, that few decorators means calling of few virtual functions, which also make the code much harder to optimize by compiler. We need to rising with decorator. I have good news for you: No, you don't need to! We have two types of polymorphism, so instead of using a dynamic one, let's use a static.


Get rid of virtual

First, the class Cargo is no longer virtual. We don't need to implement rule of 5 and create a virtual destructor. We move constructors to protected section, to make it create only by derived classes (sth similar to abstract class, which we can't create).

struct Cargo {
    size_t amount;

    auto operator<=>(const Cargo&) const = default;

protected:
    // Only derived class can create it
    Cargo(size_t amount)
        : amount(amount) {}
    Cargo() = default;
};

Next, we also remove all virtual functions from derived classes.

struct Fruit : public Cargo {
    static constexpr int BASE_PRICE = 10;

    Fruit(size_t amount)
        : Cargo(amount) {}

    size_t getPrice() const {
        return BASE_PRICE;
    }

    const std::string& name() const {
        static const std::string name = "Banana";
        return name;
    }
};
struct Item : public Cargo {
    static constexpr int BASE_PRICE = 100;

    Item(size_t amount)
        : Cargo(amount) {}

    size_t getPrice() const {
        return BASE_PRICE;
    }

    const std::string& name() const {
        static const std::string name = "Item";
        return name;
    }
};

We don't need to have a base class for decorator. We can move directly to implementation.

template <typename Neasted>
class Vulnerable : public TimeObserver {
public:
    Vulnerable(Neasted neasted, Time* time, int durability, int maxDurability)
        : _neasted(neasted), _time(time), _durability(durability), _maxDurability(maxDurability) {
        assert(_time);
        time->attach(this);
    }

    // Rule of 5
    ~Vulnerable() { _time->detach(this);}

    void nextDay() override {
        _durability = std::max(0, _durability - 1);
    }

    size_t getPrice() const {
        return (_durability * _neasted.getPrice()) / _maxDurability;
    }

    const std::string& name() const {
        return _neasted.name();
    }

private:
    Neasted _neasted;
    Time* _time;
    int _durability;
    int _maxDurability;
};

As you can see, we have even less code, and we avoid a lot of virtual functions. If we decide that an object is too heavy to copy, we can pass a pointer. The main bottleneck of decorator which slows down the code was calling a lot of virtual functions. Without virtual functions, our code is much faster.

template <typename Neasted, typename ValueType>
class Valuable {
public:
    Valuable(Neasted neasted, ValueType value)
        : _neasted(neasted), _value(value) {
    }

    size_t getPrice() const {
        return _neasted.getPrice() * static_cast<int>(_value);
    }

    const std::string& name() const {
        return _neasted.name();
    }

private:
    Neasted _neasted;
    ValueType _value;
};

Even usage seems more intuitive, because we don't need to create a lot of unique_ptr even for small objects.

int main() {
    Time time;
    auto cargo = Vulnerable(Valuable(Item(10), ItemType::Epic), &time, 100, 100);
    auto cargo2 = Valuable(Item(10), ItemType::Epic);

    for (int i = 1; i <= 15; ++i) {
        std::cout << "DAY: " << i << " | Name: " << cargo.name() << " | Price: " << cargo.getPrice();
        std::cout << " |-| Name: " << cargo2.name() << " | Price: " << cargo2.getPrice() << '\n';
        ++time;
    }
}
DAY: 1 | Name: Item | Price: 1000 |-| Name: Item | Price: 1000
DAY: 2 | Name: Item | Price: 990 |-| Name: Item | Price: 1000
DAY: 3 | Name: Item | Price: 980 |-| Name: Item | Price: 1000
DAY: 4 | Name: Item | Price: 970 |-| Name: Item | Price: 1000
DAY: 5 | Name: Item | Price: 960 |-| Name: Item | Price: 1000
DAY: 6 | Name: Item | Price: 950 |-| Name: Item | Price: 1000
DAY: 7 | Name: Item | Price: 940 |-| Name: Item | Price: 1000
DAY: 8 | Name: Item | Price: 930 |-| Name: Item | Price: 1000
DAY: 9 | Name: Item | Price: 920 |-| Name: Item | Price: 1000
DAY: 10 | Name: Item | Price: 910 |-| Name: Item | Price: 1000
DAY: 11 | Name: Item | Price: 900 |-| Name: Item | Price: 1000
DAY: 12 | Name: Item | Price: 890 |-| Name: Item | Price: 1000
DAY: 13 | Name: Item | Price: 880 |-| Name: Item | Price: 1000
DAY: 14 | Name: Item | Price: 870 |-| Name: Item | Price: 1000
DAY: 15 | Name: Item | Price: 860 |-| Name: Item | Price: 1000

Ecerice 1

  • Go into directory Ship and implement DecoratedCargo:
    • It should take a std::unique_ptr<Cargo> and has protected getter to this field.
  • Implement Vunreable class:
    • It should inherit from TimeObserver and DecoratedCargo
    • It should take additional parameters in C'tor: Time, durability and maxDurability
    • It should simulate time elapsing. Every day should subtract durability
    • It should return price back on current durability of cargo

Exercise 2

  • Implement Valuable class:
    • It should inherit from DecoratedCargo
    • it should be a template that takes enum type of value
    • It should return value based on type (cast it to enum and multiply by current value)
  • Try to compile code form main.cpp
  • You may have some trouble with running code if you don't write a code carefully :) think where is a problem.