Avatud laiendamisele, suletud muutmisele ehk C++ ja staatiline polümorfism

26.01.2018

Kui sissejuhatuseks rääkida veidi raamatutest, siis Robert C. Martin-i sulest ilmunud teos "Agile Principles, Patterns, and Practices in C#" on klassika, mis ilmselt tasub küll igal programmeerijal läbi lugeda. Siin tutvustatud niinimetatud SOLID printsiibid on väärt rakendamist, kuigi alati ei õnnestu see tavaliselt 100%-liselt.

Võtame korraks vaatluse alla "Avatud laiendamisele, suletud muutmisele" põhimõtte, millega on tihedalt seotud ka SRP.

Meenus ammune juuni 2008 MSDN-s ilmunud Jeremy Miller kirjutatud artikkel "The Open Closed Principle". Seal on kasutusel muidugi dünaamiline polümorfism. Kuid C++ võimaldab ka staatilist polümorfismi ilma VTABLE-t kasutamata. Eriti meeldib see mikrokontrollerite programmeerijatele, sest see võimaldab tihtipeale mälu kokku hoida ja töötab ka kiiremini.

Alustame lihtsa näitega, kus kasutame tavalist if-else hargnemist. Me ei järgi siin erilisi printsiipe, kasutame lihtsalt niiöelda oma talupojamõistust:

#include <iostream>

enum class OrderType {
    Domestic, International
};

struct Order {
    int order_id;
    int customer_id;
    OrderType orderType;
};

struct SelectAndProcess {
    static void execute(Order &order) {
        if(order.orderType == OrderType::International)
        {
            std::cout << "Processing InternationalOrder id:" << order.order_id << '\n';
        }
        else if (order.orderType == OrderType::Domestic) {
            std::cout << "Processing DomesticOrder id:" << order.order_id << '\n';
        }
    }
};

int main() {
    Order domestic{1, 1, OrderType::Domestic};
    Order international{2, 3, OrderType::International};
    Order domestic2{3, 4, OrderType::Domestic};
    SelectAndProcess::execute(domestic);
    SelectAndProcess::execute(international);
    SelectAndProcess::execute(domestic2);
    return 0;
}

Lihtsuse mõttes on kõik struktuurid ja klassid samas failis. Need saaks küll paigutada erinevatesse failidesse, kuid me ei keskendu praegu sellele.

Seda koodi võib vaadata aadressilt: https://godbolt.org/g/GgNsJA. Assembleris on siin 74 rida.

Ülaltoodud kood on ilmselt esimene viis, kuidas me programmeerijatena oleme alustanud. Suureks plussiks on siin lihtsus. Koodi ei ole ka palju ja see on praegu lihtsasti arusaadav. Programm väljastab:

Processing DomesticOrder id:1
Processing InternationalOrder id:2
Processing DomesticOrder id:3

Kogenud programmeerija näeb siin siiski probleeme:

  • struct SelectAndProcess on reaalses elus kindlasti palju keerulisem. Mõistlik oleks, et iga if lause taga olev kood oleks omaette funktsioonis või pigem klassis. Võime ette kujutada (kindlasti oleme ise näinud või ka ise kirjutanud), kui pikaks see execute meetod võib minna.
  • Kui soovime näiteks lisada uue Order tüübi Special, siis peaksime lisame veel ühe if lause ning meie execute meetod venib veelgi pikemaks. Kuidas oleks tulevikus sellist koodi hooldada? See ei ole väga tore. Vigu võib kergesti tulla ja see võib olla aeganõudev.
  • See kood ei vasta ei SRP ega ka OCP põhimõttele.

Järgmisena vaatame kuidas lahendada sama ülesanne dünaamilise polümorfismi abil, kasutades abstraktset baasklassi. Sisuliselt peame muutma vaid SelectAndProcess avalikku klassi.

#include <iostream>

enum class OrderType {
    Domestic, International
};

struct Order {
    int order_id;
    int customer_id;
    OrderType orderType;
};

struct OrderHandler {
    virtual ~OrderHandler() {}

    virtual void process_order(Order &order)= 0;

    virtual bool can_process(Order &order)= 0;
};

struct DomesticOrderProcessor : OrderHandler {
    void process_order(Order &order) override {
        std::cout << "Processing DomesticOrder id:" << order.order_id << '\n';
    }

    bool can_process(Order &order) override {
        return (order.orderType == OrderType::Domestic);
    }
};

struct InternationalOrderProcessor : OrderHandler {
    void process_order(Order &order) override {
        std::cout << "Processing InternationalOrder id:" << order.order_id << '\n';
    }

    bool can_process(Order &order) override {
        return (order.orderType == OrderType::International);
    }
};


struct SelectAndProcess {
    static void execute(Order &order) {
        DomesticOrderProcessor domesticOrderProcessor;
        InternationalOrderProcessor internationalOrderProcessor;
        OrderHandler *arr[] = {&domesticOrderProcessor, &internationalOrderProcessor};
        int size = sizeof(arr) / sizeof(arr[0]);
        for (int i = 0; i < size; ++i) {
            if (arr[i]->can_process(order)) {
                arr[i]->process_order(order);
                return;
            }
        }
    }
};

int main() {
    Order domestic{1, 1, OrderType::Domestic};
    Order international{2, 3, OrderType::International};
    Order domestic2{3, 4, OrderType::Domestic};
    SelectAndProcess::execute(domestic);
    SelectAndProcess::execute(international);
    SelectAndProcess::execute(domestic2);
    return 0;
}

Käivitame selle ja tulemus on sama:

Processing DomesticOrder id:1
Processing InternationalOrder id:2
Processing DomesticOrder id:3

Assembleris on ridu gcc-ga kõvasti rohkem: https://godbolt.org/g/itBHRg. Siin on näha ka, et genereeriti vastavad vtable read.

Kui vaadata clang abil genereeritud listingut https://godbolt.org/g/jwHW3r, siis on ridu kõvasti vähem ja ka vtable read puuduvad.

Nüüd siis sama asi staatilist polümorfismi kasutades. Ilma template võimalust kasutades näeb kood välja niimoodi:

#include <iostream>

enum class OrderType {
    Domestic, International
};

struct Order {
    int order_id;
    int customer_id;
    OrderType orderType;
};

struct DomesticOrderProcessor {
    bool process(Order &order)
    {
        if (order.orderType == OrderType::Domestic) {
            std::cout << "Processing DomesticOrder id:" << order.order_id << '\n';
            return true;
        }
        return false;
    }
};

struct InternationalOrderProcessor {
    bool process(Order &order) {
        if (order.orderType == OrderType::International) {
            std::cout << "Processing InternationalOrder id:" << order.order_id << '\n';
            return true;
        }
        return false;
    }
};

inline int process_order(InternationalOrderProcessor &orderp, Order &order) {
    return orderp.process(order);
}

inline int process_order(DomesticOrderProcessor &orderp, Order &order) {
    return orderp.process(order);
}

struct SelectAndProcess {
    static int execute(Order &order) {
        DomesticOrderProcessor domestic_order;
        InternationalOrderProcessor international_order;
        if (process_order(domestic_order, order)) return 0;
        process_order(international_order, order);
        return 0;
    }
};

int main() {
    Order domestic{1, 1, OrderType::Domestic};
    Order international{2, 3, OrderType::International};
    Order domestic2{3, 4, OrderType::Domestic};
    SelectAndProcess::execute(domestic);
    SelectAndProcess::execute(international);
    SelectAndProcess::execute(domestic2);
    return 0;
}

Assembleri listingus saime 78 koodirida: https://godbolt.org/g/j8PBxU

Kasutasime siin ära selle, et vastavalt meetodi parameetritele käivitatakse ka vastav funktsioon. Järgmisena võtame appi C++ template, mis võimaldab meil koodi mitte korrata:

template<typename order_processor>
int process_order(order_processor &orderp, Order &order) {
    return orderp.process(order);
}

kogu programm:

#include <iostream>

enum class OrderType {
    Domestic, International
};

struct Order {
    int order_id;
    int customer_id;
    OrderType orderType;
};

struct DomesticOrderProcessor {
    bool process(Order &order)
    {
        if (order.orderType == OrderType::Domestic) {
            std::cout << "Processing DomesticOrder id:" << order.order_id << '\n';
            return true;
        }
        return false;
    }
};

struct InternationalOrderProcessor {
    bool process(Order &order) {
        if (order.orderType == OrderType::International) {
            std::cout << "Processing InternationalOrder id:" << order.order_id << '\n';
            return true;
        }
        return false;
    }
};

template<typename order_processor>
int process_order(order_processor &orderp, Order &order) {
    return orderp.process(order);
}

struct SelectAndProcess {
    static int execute(Order &order) {
        DomesticOrderProcessor domestic_order;
        InternationalOrderProcessor international_order;
        if (process_order(domestic_order, order)) return 0;
        process_order(international_order, order);
        return 0;
    }
};

int main() {
    Order domestic{1, 1, OrderType::Domestic};
    Order international{2, 3, OrderType::International};
    Order domestic2{3, 4, OrderType::Domestic};
    SelectAndProcess::execute(domestic);
    SelectAndProcess::execute(international);
    SelectAndProcess::execute(domestic2);
    return 0;
}

Assembleri listing https://godbolt.org/g/Qf3F9F on tegelikult täpselt sama.

Lõpuks veel sama asi CRTP abil:

#include <iostream>

enum class OrderType {
    Domestic, International
};

struct Order {
    int order_id;
    int customer_id;
    OrderType orderType;
};

template<class Derived>
struct base {
    void process() {
        static_cast<Derived *>(this)->process();
    };
};

struct DomesticOrderProcessor : base<DomesticOrderProcessor> {
    bool process(Order &order) {
        if (order.orderType == OrderType::Domestic) {
            std::cout << "Processing DomesticOrder id:" << order.order_id << '\n';
            return true;
        }
        return false;
    }
};

struct InternationalOrderProcessor : base<InternationalOrderProcessor> {
    bool process(Order &order) {
        if (order.orderType == OrderType::International) {
            std::cout << "Processing InternationalOrder id:" << order.order_id << '\n';
            return true;
        }
        return false;
    }
};

template<typename order_processor>
int process_order(order_processor &orderp, Order &order) {
    return orderp.process(order);
}

struct SelectAndProcess {
    static int execute(Order &order) {
        DomesticOrderProcessor domestic_order;
        InternationalOrderProcessor international_order;
        if (process_order(domestic_order, order)) return 0;
        process_order(international_order, order);
        return 0;
    }
};

int main() {
    Order domestic{1, 1, OrderType::Domestic};
    Order international{2, 3, OrderType::International};
    Order domestic2{3, 4, OrderType::Domestic};
    SelectAndProcess::execute(domestic);
    SelectAndProcess::execute(international);
    SelectAndProcess::execute(domestic2);
    return 0;
}

Genereeritud assembleri listing on ikka sama: https://godbolt.org/g/e4s5iF. Koodi tuli juurde, aga olulist võitu sellest ei tulnud.

Mida öelda kokkuvõtteks?

  • On hea teada nii dünaamilise kui staatilise polümorfismi võimalusi. See aitab kirjutada kergemini hallatavad koodi.
  • Tasub vaadata ja võrrelda genereeritud assembleri listinguid. Optimeeritud kompilaator võib meid üllatada. Näiteks clang-i genereeritud dünaamilise polümorfismiga variant ei sisaldanudki vtable ridu.

linke:

https://www.safaribooksonline.com/library/view/agile-principles-patterns/0131857258/

https://en.wikipedia.org/wiki/SOLID_(object-oriented_design)

https://en.wikipedia.org/wiki/Open/closed_principle

https://en.wikipedia.org/wiki/Single_responsibility_principle

https://blogs.msdn.microsoft.com/msdnmagazine/2008/07/02/patterns-in-practice-the-open-closed-principle/

http://download.microsoft.com/download/3/a/7/3a7fa450-1f33-41f7-9e6d-3aa95b5a6aea/MSDNMagazine2008_06en-us.chm

https://en.wikipedia.org/wiki/Template_metaprogramming#Static_polymorphism

Real-Time C++ http://www.springer.com/gp/book/9783662478097