以深度学习为例理解22种设计模式(一)创建型模式

本系列包括3篇文章,以深度学习的模型搭建和训练过程为例,解释面向对象编程中22种设计模式的基本原理,并给出C++实现。

这些设计模式的实现方法大多参考自《深入设计模式》。为了用尽可能少的代码体现这些设计模式的过程,在代码中直接对指针进行操作,并没有使用智能指针。

该篇文章介绍创建型模式,包括工厂方法、抽象工厂、生成器、原型以及单例。

本文的所有代码都在我的GitHub上:github.com/johnhany/design_patterns


创建型模式

创建型模式(Creational Pattern)主要关注对象创建过程的灵活性和可复用性。

工厂方法

工厂方法(Factory Method)模式适用于将对象的创建和使用分离,并希望尽可能多地复用现有代码来处理新型对象的情景。

比如,我们已经实现了一个CNN模型的训练,现在需要再实现一个GAN模型的训练过程。由于GAN模型本身结构和训练过程和CNN很不一样(例如GAN一般至少包含两个网络,而且训练时采用对抗训练的策略),我们需要用不同的接口来创建和训练两种模型。但是这两种任务又存在相同之处,比如初始化模型前读取一些超参,训练完成后保存模型文件等。这时,我们可以用工厂方法模式在保证尽可能地利用已有代码的前提下,用类似的接口完成对CNN和GAN的训练。

首先,定义一个Network基类:

class Network {
public:
    virtual ~Network() { cout << "Network destroyed" << endl; }
    virtual string getNetworkType() const = 0;
};

其中,纯虚函数getNetworkType()用来返回网络的类型。我们这里用返回的不同的string来表示不同的网络。

然后就可以继承出CNN和GAN模型了:

class CNN : public Network {
public:
    string getNetworkType() const override {
        return "CNN";
    }
};

class GAN : public Network {
public:
    string getNetworkType() const override {
        return "GAN";
    }
};

两种网络已经定义好了。下面我们针对两种网络实现不同的训练接口。

类似地,再定义一个Trainer基类:

class Trainer {
public:
    virtual ~Trainer(){ cout << "Trainer destroyed" << endl; };
    virtual Network* createNetwork() const = 0;

    void whatModelAmITraining() const {
        Network* network = this->createNetwork();
        cout << "I am training a " + network->getNetworkType() + " model." << endl;
        delete network;
        return;
    }
};

继承的Trainer子类只需要提供createNetwork()的实现:

class CNNTrainer : public Trainer {
public:
    Network* createNetwork() const override {
        return new CNN();
    }
};

class GANTrainer : public Trainer {
public:
    Network* createNetwork() const override {
        return new GAN();
    }
};

这样,我们不需要针对不同子类来修改whatModelAmITraining(),就可以直接利用CNNTrainer训练CNN,用GANTrainer训练GAN了。就像这样:

void train_api(const Trainer& trainer) {
    trainer.whatModelAmITraining();
}

train_api当中,我们不需要关心正在处理的NetworkTrainer的具体类型就可以直接调用同一个模型训练接口。

于是,在main()函数里:

int main() {
    cout << "I'm going to train a CNN model." << endl;
    Trainer* trainer1 = new CNNTrainer();
    train_api(*trainer1);

    cout << "I'm going to train a GAN model." << endl;
    Trainer* trainer2 = new GANTrainer();
    train_api(*trainer2);

    delete trainer1;
    delete trainer2;

    return 0;
}

运行的结果如下所示:

I'm going to train a CNN model.
I am training a CNN model.
Network destroyed
I'm going to train a GAN model.
I am training a GAN model.
Network destroyed
Trainer destroyed
Trainer destroyed

工厂方法模式可以演化为抽象工厂模式、生成器模式和原型模式。

抽象工厂

抽象工厂(Abstract Factory)适用于需要创建许多种一系列相互依赖的对象的情景。

比如,我们在上面的代码中已经实现了两种不同的模型CNN和GAN,但为了完成模型的训练,我们还需要提供分别适用于CNN和GAN的数据集(例如ImageNet和CelebA)。于是,两种模型训练过程就变成了“ImageNet对象->CNN对象->CNNTrainer对象”和“CelebA对象->GAN对象->GANTrainer对象”这两组相互依赖的对象的创建过程。

我们先用工厂方法定义两种Dataset类型:

class Dataset {
public:
    virtual ~Dataset() { cout << "Dataset destroyed" << endl; }
    virtual string getDatasetName() const = 0;
};

class ImageNet : public Dataset {
public:
    string getDatasetName() const override {
        return "ImageNet";
    }
};

class CelebA : public Dataset {
public:
    string getDatasetName() const override {
        return "CelebA";
    }
};

然后修改一下Network,让它来调用Dataset对象:

class Network {
public:
    virtual ~Network() { cout << "Network destroyed" << endl; }
    virtual string getNetworkType() const = 0;
    virtual string giveMeADataset(const Dataset& dataset) const = 0;
};

class CNN : public Network {
public:
    string getNetworkType() const override {
        return "CNN";
    }
    string giveMeADataset(const Dataset& dataset) const override {
        const string dataset_name = dataset.getDatasetName();
        return "I am training a CNN with " + dataset_name;
    }
};

class GAN : public Network {
public:
    string getNetworkType() const override {
        return "GAN";
    }
    string giveMeADataset(const Dataset& dataset) const override {
        const string dataset_name = dataset.getDatasetName();
        return "I am training a GAN with " + dataset_name;
    }
};

在两个Network的子类里,我们调用Dataset接口的方式是相同的:dataset.getDatasetName(),区别在于我们需要在CNN的实现当中只关心CNN的数据预处理,在GAN当中只关心GAN的数据预处理。

之后,继续修改Trainer的实现:

class Trainer {
public:
    virtual ~Trainer(){ cout << "Trainer destroyed" << endl; };
    virtual Network* createNetwork() const = 0;
    virtual Dataset* createDataset() const = 0;
};


class CNNTrainer : public Trainer {
public:
    Network* createNetwork() const override {
        return new CNN();
    }
    Dataset* createDataset() const override {
        return new ImageNet();
    }
};

class GANTrainer : public Trainer {
public:
    Network* createNetwork() const override {
        return new GAN();
    }
    Dataset* createDataset() const override {
        return new CelebA();
    }
};

当然,现在我们的Trainer只负责为CNN创建相应的DatasetNetwork,为GAN创建另一套DatasetNetwork。也就是说,Trainer只负责对象的创建,具体如何使用这些对象,则需要在Trainer外部实现:

void train_api(const Trainer& trainer) {
    const Dataset* dataset = trainer.createDataset();
    const Network* network = trainer.createNetwork();
    cout << "I want to train a " + network->getNetworkType() + " model." << endl;
    cout << network->giveMeADataset(*dataset) << endl;
    delete dataset;
    delete network;
    return;
}

同样地,在train_api里面不需要关心DatasetNetwork的具体类型。

最后,给出main()函数:

int main() {
    CNNTrainer* trainer1 = new CNNTrainer();
    train_api(*trainer1);
    delete trainer1;

    GANTrainer* trainer2 = new GANTrainer();
    train_api(*trainer2);
    delete trainer2;

    return 0;
}

这一部分代码的运行结果如下所示:

I want to train a CNN model.
I am training a CNN with ImageNet
Dataset destroyed
Network destroyed
Trainer destroyed
I want to train a GAN model.
I am training a GAN with CelebA
Dataset destroyed
Network destroyed
Trainer destroyed

工厂方法与抽象工厂的根本区别在于工厂方法是“方法”,抽象工厂是“对象”。具体地,工厂方法的目的在于用基类的不同子类来表示不同的对象;抽象工厂只需要关心如何创建一系列相互依赖的对象,可以理解为一种包含很多工厂方法的对象。

生成器

生成器(Builder)模式适用于需要分很多步骤创建不同对象的情景。也就是说我所创建的对象可以由许多重复性的基本组件构成。

比如,基于神经网络的分类模型,具体的结构可以有很多种,例如只有1个全连通层的线性模型、包含多个全连通层的MLP、以及同时包含多个卷积层和全连通层的CNN等。这些不同的模型都是由“全连通层”和“卷积层”这两个基本单元构成的。于是,我们可以用生成器模式来实现这个过程。

我们首先定义一个Network类:

class Network {
public:
    vector<string> layers;
    void showAllLayers() const {
        cout << "All layers in network: ";
        for (auto& i : layers)
            cout << i << " ";
        cout << endl;
    }
};

然后定义一个Builder基类,用来向Network添加不同类型的层:

class Builder {
public:
    virtual ~Builder() { cout << "Builder destroyed" << endl; }
    virtual void createLinearLayer() const = 0;
    virtual void createConvLayer() const = 0;
    virtual void createLossLayer() const = 0;
};

因为我们所关心的是分类问题下的各种模型结构,所以继承出一个ClassificationBuilder子类:

class ClassificationBuilder : public Builder {
private:
    Network* network;
public:
    ClassificationBuilder() {
        this->reset();
    }
    ~ClassificationBuilder() {
        delete network;
    }

    void reset() {
        this->network = new Network();
    }
    Network* getNetwork() {
        Network* network = this->network;
        this->reset();
        return network;
    }

    void createLinearLayer() const override {
        this->network->layers.push_back("Linear");
    }

    void createConvLayer() const override {
        this->network->layers.push_back("Conv");
    }

    void createLossLayer() const override {
        this->network->layers.push_back("Loss");
    }
};

这样,我们就可以用一个Director对象来提前定义几种常见模型的结构:

class Director {
private:
    Builder* builder;
public:
    void setBuilder(Builder* builder) {
        this->builder = builder;
    }

    void buildLinearModel() {
        this->builder->createLinearLayer();
        this->builder->createLossLayer();
    }

    void buildCNNModel() {
        this->builder->createConvLayer();
        this->builder->createConvLayer();
        this->builder->createLinearLayer();
        this->builder->createLinearLayer();
        this->builder->createLossLayer();
    }
};

需要注意的是,Director对象并不是生成器模式所必需的,因为我们完全可以直接调用Builder实现模型的创建:

void train_api(Director& director) {
    ClassificationBuilder* builder = new ClassificationBuilder();
    director.setBuilder(builder);

    cout << "I want to train a linear model:" << endl;
    director.buildLinearModel();

    Network* net = builder->getNetwork();
    net->showAllLayers();
    delete net;

    cout << "I want to train a CNN model:" << endl;
    director.buildCNNModel();

    net = builder->getNetwork();
    net->showAllLayers();
    delete net;

    cout << "I want to train a MLP model:" << endl;
    builder->createLinearLayer();
    builder->createLinearLayer();
    builder->createLinearLayer();
    builder->createLossLayer();

    net = builder->getNetwork();
    net->showAllLayers();
    delete net;

    delete builder;
    return;
}

最后给出main()函数:

int main(){
    Director* director= new Director();
    train_api(*director);
    delete director;

    return 0;
}

这一部分代码的执行结果如下:

I want to train a linear model:
All layers in network: Linear Loss 
I want to train a CNN model:
All layers in network: Conv Conv Linear Linear Loss 
I want to train a MLP model:
All layers in network: Linear Linear Linear Loss 
Builder destroyed

原型

原型(Prototype)模式适用于需要复制复杂的对象,并希望新复制的对象独立于原来的代码的情景。

比如,我们已经创建好一个神经网络,现在想同时在多个设备上(比如CPU和GPU,或者多个不同的GPU)运行同一个模型。我们可以为这些模型设置相同的超参,来比较不同设备的运行效率;也可以为这些模型设置不同的超参,通过比较结果来选一组最好的超参。无论是哪种情形,我们都需要设计一种接口,来很方便地复制一个模型,并且希望复制后的模型与原来的模型是互不干扰的。这时,我们可以利用原型模式来达到复制对象的目的。

首先,我们用枚举变量来标记CPU和GPU设备:

enum Device {
    DEVICE_CPU = 0,
    DEVICE_GPU
};

然后定义Model基类:

class Model {
protected:
    string model_name;
    string device_id;
public:
    Model() {}
    Model(string name) : model_name(name) {}
    virtual ~Model() { cout << "Model destroyed" << endl; }

    virtual Model* clone() const = 0;
    virtual void runOnDevice(string id) {
        this->device_id = id;
        cout << "Run " + model_name + " on device " + device_id << endl;
    }
    virtual string getTech() const = 0;
};

其中,clone()接口就是原型模式的关键所在。

由于CPU和GPU上有不同的底层优化库来帮助我们提高计算效率,我们需要分别为模型在CPU和GPU上的两个版本提供两个子类:

class CPUModel : public Model {
private:
    string cpuOptimizationTech;
public:
    CPUModel(string name, string tech) : Model(name), cpuOptimizationTech(tech) {}
    ~CPUModel() { cout << "CPU model destroyed" << endl; }
    Model* clone() const override {
        return new CPUModel(*this);
    }
    string getTech() const override {
        return cpuOptimizationTech;
    }
};

class GPUModel : public Model {
private:
    string gpuOptimizationTech;
public:
    GPUModel(string name, string tech) : Model(name), gpuOptimizationTech(tech) {}
    ~GPUModel() { cout << "GPU model destroyed" << endl; }
    Model* clone() const override {
        return new GPUModel(*this);
    }
    string getTech() const override {
        return gpuOptimizationTech;
    }
};

这样,我们就有了两个最基本的“原型”,即CPUModelGPUModel。在训练模型时,我们可以通过改变其device_id变量来指定新模型运行的具体的物理设备。比方说,如果机器上有8个GPU的话,我们可以复制出8份GPUModel,然后分别设置不同的device_id,让这些模型独立地运行于8个GPU上。

我们定义一个PrototypeFactory对象来保存最原始的CPUModelGPUModel(注意这两份最原始的拷贝也是占用内存空间的):

class PrototypeFactory {
private:
    std::unordered_map<Device, Model*, std::hash<int>> prototypes;

public:
    PrototypeFactory() {
        prototypes[Device::DEVICE_CPU] = new CPUModel("CPU Model", "AVX2");
        prototypes[Device::DEVICE_GPU] = new GPUModel("GPU Model", "CUDA");
    }
    ~PrototypeFactory() {
        delete prototypes[Device::DEVICE_CPU];
        delete prototypes[Device::DEVICE_GPU];
    }

    Model *createPrototype(Device device) {
        return prototypes[device]->clone();
    }
};

然后我们就可以利用PrototypeFactory来复制产生新的模型了:

void train_api(PrototypeFactory &prototype_factory) {
    cout << "I want to create a CPU model" << endl;
    Model* model = prototype_factory.createPrototype(Device::DEVICE_CPU);
    model->runOnDevice("cpu:0");
    cout << "Optimization technology: " + model->getTech() << endl;
    delete model;

    cout << "I want to create a GPU model" << endl;
    model = prototype_factory.createPrototype(Device::DEVICE_GPU);
    model->runOnDevice("gpu:1");
    cout << "Optimization technology: " + model->getTech() << endl;
    delete model;
}

main()函数依然很简短:

int main() {
    PrototypeFactory *prototype_factory = new PrototypeFactory();
    train_api(*prototype_factory);
    delete prototype_factory;

    return 0;
}

代码运行效果如下:

I want to create a CPU model
Run CPU Model on device cpu:0
Optimization technology: AVX2
CPU model destroyed
Model destroyed
I want to create a GPU model
Run GPU Model on device gpu:1
Optimization technology: CUDA
GPU model destroyed
Model destroyed
CPU model destroyed
Model destroyed
GPU model destroyed
Model destroyed

单例

单例(Singleton)模式适用于一个类只需要一个实例的情景。抽象工厂模式、生成器模式和原型模式都可以用单例模式实现。

比如,我们创建的模型非常庞大,机器上的内存空间(或显存)只能容纳一个模型。我们一方面要避免在代码中显示地对模型进行复制(否则内存会爆掉),另一方面在设计模型的接口时也要保证其他人在调用我们的接口时不会随意地复制模型。这时就轮到单例模式派上用场了。

我们先来定义Model对象:

class Model {
private:
    Model() {}
    ~Model() {}
protected:
    Model(const string id) : model_id(id) {}
    static Model* model;
    string model_id;
public:
    Model(const Model&) = delete;
    Model(Model&&) = delete;
    void operator=(const Model&) = delete;
    void operator=(Model&&) = delete;

    void train() {
        cout << "Training done with " + model_id + "\n";
    }

    static Model* getInstance(const string& id);
    static Model& getInstanceSafe(const string& id);
};

因为我们只希望Model自己有权对模型进行创建和删除,所以把constructordestructor声明为private。另外,我们不希望Model的调用者对模型进行复制,所以需要禁用其copy constructormove constructorcopy assignment operatormove assignment operator

然后在全局对model进行初始化,并给出getInstance()的实现:

Model* Model::model = nullptr;

Model* Model::getInstance(const string& id) {
    if (model == nullptr)
        model = new Model(id);
    return model;
}

但是需要注意的是,在C++11之前,这种单例模式在多线程下并不能保证在所有线程上也只有一个实例。比如:

void trainer1() {
    // Emulates slow initialization.
    std::this_thread::sleep_for(std::chrono::milliseconds(1000));
    Model* model = Model::getInstance("model_1");
    model->train();
}

void trainer2() {
    // Emulates slow initialization.
    std::this_thread::sleep_for(std::chrono::milliseconds(1000));
    Model* model = Model::getInstance("model_2");
    model->train();
}


int main()
{
    std::cout <<"If you see the same value, then singleton was reused\n" <<
                "If you see different values, then 2 singletons were created in different threads\n\n" <<
                "RESULT:\n";
    std::thread t1(trainer1);
    std::thread t2(trainer2);
    t1.join();
    t2.join();

    return 0;
}

在执行main()函数时,有很大的概率会得到下面这种结果:

> g++ singleton.cpp -lpthread -o singleton && ./singleton
If you see the same value, then singleton was reused
If you see different values, then 2 singletons were created in different threads

RESULT:
Training done with model_1
Training done with model_2

这说明在不同的线程上分别存在一个Model实例。如果把两个trainer函数中的sleep_for那行代码注释掉,两个线程又会有很大的可能访问同一个Model实例。

在C++11中,避免这种情况的解决方法很简单:

Model& Model::getInstanceSafe(const string& id) {
    static Model model_safe;
    model_safe.model_id = id;
    return model_safe;
}

然后把两个trainer函数改为:

void trainer1() {
    // Emulates slow initialization.
    std::this_thread::sleep_for(std::chrono::milliseconds(1000));

    Model::getInstanceSafe("model_1").train();
}

void trainer2() {
    // Emulates slow initialization.
    std::this_thread::sleep_for(std::chrono::milliseconds(1000));

    Model::getInstanceSafe("model_2").train();
}

运行结果就变为了:

> g++ singleton.cpp -std=c++11 -lpthread -o singleton && ./singleton
If you see the same value, then singleton was reused
If you see different values, then 2 singletons were created in different threads

RESULT:
Training done with model_1
Training done with model_1

这时,我们的单例实现就是线程安全(thread-safe)的了。


结构型模式

请见《以深度学习为例理解22种设计模式(二)结构型模式》


行为模式

请见《以深度学习为例理解22种设计模式(三)行为模式》


本文的所有代码都在我的GitHub上:github.com/johnhany/design_patterns

把这篇文章分享给你的朋友:
Subscribe
订阅评论
guest
2 评论
最新
最旧 得票最多
Inline Feedbacks
View all comments
trackback
3 月 之前

[…] 请见《以深度学习为例理解22种设计模式(一)创建型模式》。 […]

trackback
3 月 之前

[…] 请见《以深度学习为例理解22种设计模式(一)创建型模式》。 […]