C++ 中 Concept-Model 概念模型

此文檔參考自:https://gracicot.github.io/conceptmodel/2017/09/13/concept-model-part1.html ,覺得很有趣,就翻譯過來了

一、Concept-Model:多態的新視角

面向對象編程大家都很熟悉,只需實現一個接口 Interface 。但這種使用經典 OOP 實現的多態性是侵入性的,即使在真正不需要的地方也會強制多態行爲,比如總會觸發堆分配,伴隨着一個包含基類的列表。今天我們要介紹一種新的概念模型( Runtime ConceptVirtual Concept ),這是一種可能會改變你對多態性及其試圖解決的問題的看法的模型。
讓我們從一個簡單的例子開始:樣例中有一個接口 abstract_task ,一個或多個實現(即 print_task ),一個類型擦除列表 tasks,以多態方式執行該 process 函數。

// Our interface.
struct abstract_task {
    virtual void process() = 0;
    virtual ~abstract_task() = default;
};

// An implementation of our interface.
struct print_task : abstract_task {
    void process() override {
        std::cout << "Print Task" << std::endl;
    }
};

// A type erased list of tasks.
std::vector<std::unique_ptr<abstract_task>> tasks;

// A function that push a new task in the list.
void push(std::unique_ptr<abstract_task> task) {
    tasks.emplace_back(std::move(task));
}

// execute all tasks and clear the list.
void run() {
    for(auto&& task : tasks) {
        task->process();
    }
    
    tasks.clear();
}

上面的代碼符合我們大部分的編程直覺。首先,這裏需要動態分配,且沒有辦法解決它。但真實意圖不是“我們想要 100% 的時間進行動態分配!” ,真實意圖是“我們想要一個類型擦除的任務列表”。然而,始終通過動態分配和多態恰好是最常見的方式,而且它也是語言自動實現多態的唯一方式。
其次,它不適用於所有類,很多人可能會說:

是的,只需實現該接口即可!所有類型都有效!

問題是,並非所有類型都可以繼承 abstract_task 。假設有這樣一個類:

struct some_library_task : library_task {
    void process() { /* ... */ }
};

且要求你不能更改該類,你必須實現一個 Adaptor 才能使其在代碼中工作。
此外,還有另一種類不可能擴展接口:lambdas 。是的,lambda !你的代碼可能與他們不兼容!想象一下寫這樣的東西:

push([] { std::cout << "print something!"; });

遺憾的是,這行不通,因爲 lambda 不是動態分配的,也不會擴展類 abstract_task
Concept-Model 慣用法旨在解決這些問題,我們來具體是怎麼實現的。

二、Concept-Model: The adapter pattern on steroids

在本節中,我們將解釋從經典 OOP 到 Concept-Model 的遷移過程。將會分解爲許多小步驟,以便更容易理解。
首先,函數 push 接受一個 std::unique_ptr。想象一下,假如你有幾十個函數以這種方式執行任務,如果有一天你需要所有那些採用 std::unique_ptr<abstract_task> 原始指針或引用的函數怎麼辦?我們先從這一點入手:取而代之的是包含指針的結構:

struct task {
    task(std::unique_ptr<abstract_task> t) noexcept : wrapped{std::move(t)} {}

    std::unique_ptr<abstract_task> wrapped;
};

// A vector of task, which wrap a unique pointer.
std::vector<task> tasks;

// We take a task by value, since it's constructible from a unique pointer.
void push(task t) {
    tasks.emplace_back(std::move(t));
}

但現在還是有些問題,some_task.wrapped->process()的用法會很難受,繼續調整:

struct task {
    task(std::unique_ptr<abstract_task> t) noexcept : wrapped{std::move(t)} {}
    
    void process() {
        wrapped->process();
    }
    
private:
    std::unique_ptr<abstract_task> wrapped;
};

void run() {
    for(auto&& task : tasks) {
        task.process();
    }
    
    tasks.clear();
}

現在已經很不錯了!對於任何地方 std::unique_ptr<abstract_task> ,你都可以放到 tasks裏(隱式構造),且是 pass-by-value

push(std::make_unique<print_task>());

但是等等……這並沒有解決我們的問題!我們想要支持 lambda,改變對象的發送方式,避免堆分配,這真的有用嗎?

當然!在該列表中,我們現在可以做一件事:改變傳遞對象的方式。無需更改 200 個函數簽名,我們只需更改 task 的構造函數。
現在,希望 push 函數能夠接收 some_library_task 。爲此,我們需要一個 Adaptor 來使這些類型適應接口abstract_task

// Our adapter. We contain a library task and implementing the abstract_task interface
struct some_library_task_adapter : abstract_task {
    some_library_task_adapter(some_library_task t) : task{std::move(t)} {}

    void process() override {
        task.process();
    }
    
    some_library_task task;
};

struct task {
    task(std::unique_ptr<abstract_task> t) noexcept : wrapped{std::move(t)} {}
    
    // We can now receive a library task by value.
    // We move it into a new instance of adapter.
    task(some_library_task t) noexcept :
        wrapped{std::make_unique<some_library_task_adapter>(std::move(t))} {}
    
    void process() {
        wrapped->process();
    }
    
private:
    std::unique_ptr<abstract_task> wrapped;
};

int main() {
    // push a new task to the vector
    push(some_library_task{});
}

到此,我們可以通過 pass-by-value 方式來 push 未繼承 abstract_tasksome_library_task 對象。
但是,那些繼承自 abstract_tasktask 還不能 pass-by-value,而必須使用 ptr。因此,我們需要將爲每個類創建一個 Adaptor, 但我們不希望任何外部類擴展 abstract_task,因此它將是一個私有成員類型:

struct task {
    task(some_library_task task) noexcept :
        self{std::make_unique<library_model_t>(std::move(t))} {}
    task(print_task task) noexcept :
        self{std::make_unique<print_model_t>(std::move(t))} {}
    task(some_other_task task) noexcept :
        self{std::make_unique<some_other_model_t>(std::move(t))} {}
    
    void process() {
        self->process();
    }
    
private:
    // This is our interface, now named concept_t instead of abstract_task
    struct concept_t {
        virtual ~concept_t() = default;
        virtual void process() = 0;
    };
    
    // We name our struct `model` instead of `adapter`
    struct library_model_t : concept_t {
        library_model_t(some_library_task s) noexcept : self{std::move(s)} {}
        void process() override { self.process(); }
        some_library_task self;
    };
    
    struct print_model_t : concept_t {
        library_model_t(print_task s) noexcept : self{std::move(s)} {}
        void process() override { self.process(); }
        print_task self;
    };
    
    struct some_other_model_t : concept_t {
        library_model_t(some_other_task s) noexcept : self{std::move(s)} {}
        void process() override { self.process(); }
        some_other_task self;
    };
 
    // We quite know it's wrapped. Let's name it self
    std::unique_ptr<concept_t> self;
};

這太荒謬了!我們總不能爲所有的 abstract_task 的派生類都複製一份構造函數,以及繼承 concept_t的子類代碼。

的確,C++ 中有一個很棒的工具,它經過精心設計,可以避免無意識的複製粘貼:模板!

struct task {
    template<typename T>
    task(T t) noexcept : self{std::make_unique<model_t<T>>(std::move(t))} {}
    
    void process() {
        self->process();
    }
    
private:
    struct concept_t {
        virtual ~concept_t() = default;
        virtual void process() = 0;
    };
    
    template<typename T>
    struct model_t : concept_t {
        model_t(T s) noexcept : self{std::move(s)} {}
        void process() override { self.process(); }
        T self;
    };

    std::unique_ptr<concept_t> self;
};

int main() {
    // natural syntax for object construction! Yay!
    push(some_library_task{});
    push(my_task{});
    push(print_task{});
}

問題解決了!我們代碼的 API 中不再有複製粘貼,不再有繼承,不再有指針!

三、Conclusion

這個 Concept-Model 是如何解決我們在開頭列出的所有問題?
首先,它可以自然地應用多態性,與其他代碼看起來很統一,語法也更簡潔。

void do_stuff() {
    // Initialize a std::string using a value in direct initialization 
    std::string s{"value"};
    
    // Pretty similar syntax eh?
    task t{print_task{}};
    
    // Or if you like AAA style
    auto s2 = std::string{"potato"};
    auto t2 = task{print_task{}};
    
    // use string like this
    auto size = s.size();
    
    // use task like that. Again, pretty similar 
    t.process();
}

沒有箭頭,沒有 new ,沒有std::make_*。所有的多態性隱藏在實現細節中,潛在的生效的。其次,它避免了堆分配。是的,即使我們在內部通過唯一指針傳遞我們的對象。

void do_stuff() {
    some_task t;
    
    // do some stuff with task
    t.stuff();
    
    // maybe push the task
    if (condition()) {
        push(std::move(t));
    }
}

在上面示例中,t有條件地被推入列表。如果我們不需要堆分配和多態性,我們可以在運行時決定不使用它。還有其他策略,比如使用 SBO 來避免動態分配,我將在其他部分介紹。
第三,我們的任務實現可以按照 process 自己想要的方式實現功能。例如:

struct special_task {
    int process(bool more_stuff = false) const {
        // ...
    }
};

這仍然滿足了這個概念。t.process() 即使函數是常量、接受可選參數或具有不同的返回類型,我們仍然可以調用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章