设计模式之美(C++)

设计原则与思想

面向对象

封装、抽象、继承、多态分别可以解决哪些编程问题

封装

  • what:隐藏信息,保护数据访问
  • how:暴露有限的接口和属性,通过 public/private
  • why:提供有限的接口能让使用者更简单的上手;控制属性访问权限能防止被偷偷地修改

抽象

  • what:隐藏方法的具体实现
  • how:具体实现放到 .cpp 里
  • why:人脑只需要关注功能就可以了,更多的实现细节当想要深入了解时再去关注

继承

  • what:表示类之间的 is-a 关系
  • how:class Derived : public Base
  • why:代码复用

多态

  • what:子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现
  • how:运行期使用虚函数,编译期使用CRTP
  • why:提高代码的复用性和可扩展性

为什么说要多用组合少用继承?

假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念,定义为一个抽象类 AbstractBird。所有更细分的鸟,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。
我们知道,大部分鸟都会飞,那我们可不可以在 AbstractBird 抽象类中,定义一个 fly() 方法呢?答案是否定的。尽管大部分鸟都会飞,但也有特例,比如鸵鸟就不会飞。
鸵鸟继承具有 fly() 方法的父类,那鸵鸟就具有“飞”这样的行为,这显然不符合我们对现实世界中事物的认识。
我们可以定义一个实现了 fly() 方法的 FlyAbility 类,这样当一只鸟可以飞时就把 FlyAbility 类组合进来,否则就忽略。

struct FlyAbility
{
    void fly();
};

struct FlyableBird
{
    FlyAbility flyFunc;
    // some other func
};

struct UnFlyableBird
{
    // some other func
};

设计原则

单一职责原则(SRP, the single responsibility principle)

一个类或者模块只负责完成一个职责

开闭原则(OCP, the open closed principle)

软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”

比如有如下报警类,当频率过高或者报错过多时都会报警

struct Alert 
{
    void check(int fre, int errorCnt) {
        if (fre > 10) {
            // call some alert func
        }
        if (errorCnt > 100) {
            // too many errors, call error alert func
        }
    }
};

现在我们要添加一个新的报警,当超时时间超过阈值时报警,该如果改动呢?

我们可以直接修改check代码:

struct Alert 
{
    // 改动一
    void check(int fre, int errorCnt, int timeout) {
        if (fre > 10) {
            // call some alert func
        }
        if (errorCnt > 100) {
            // two many errors, call error alert func
        }
        // 改动二
        if (timeout> 1000) {
            // call timeout alert func
        }
    }
};

这里有两处修改,一是函数参数修改,二是函数实现修改。那如果再加一种报警条件,又会多两处修改... 并且我们把函数入参修改了,其他调用check函数的地方都要跟着修改...

上面的代码改动是基于“修改”的方式来实现新功能的。如果我们遵循开闭原则,也就是“对扩展开放、对修改关闭”。那如何通过“扩展”的方式,来实现同样的功能呢?

struct AlertInfo
{
    int fre, errorCnt, timeout;
};

struct AlerHandler
{
    virtual void check(AlertInfo& info);
};

struct FreAlerHandler : public AlerHandler {};
struct ErrorAlerHandler : public AlerHandler {};
struct TimeoutAlerHandler : public AlerHandler {};

struct Alert 
{
    void addHandler(AlerHandler handler) {
        m_handlers.emplace_back(handler);
    }
    void check(AlertInfo& info) {
        for (auto& handler : m_handlers) {
            handler.check(info);
        }
    }
};

我们首先定义一个 AlertInfo 类,所有的检查信息都放到这个类中,这样check函数入参固定了,从而解决了改动一
再定义一个 AlerHandler 类,不同的检查继承该类并实现对应的check函数,并通过 Alert::addHandler 添加进来,从而解决了改动二

重构之后的代码更加灵活和易扩展。如果我们要想添加新的告警逻辑,只需要基于扩展的方式创建新的 handler 类即可,不需要改动原来的 check() 函数的逻辑

里氏替换原则(LSP, the liskov substitution principle)

子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

接口隔离原则(ISP, the interface segregation principle)

  • 如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。
  • 如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。
  • 如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。

依赖倒置原则(DIP, the dependency inversion principle)

高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象。

KISS原则(Keep It Simple and Stupid)

  • 不要使用同事可能不懂的技术来实现代码;
  • 不要重复造轮子,要善于使用已经有的工具类库;
  • 不要过度优化。

YAGNI原则(You Ain’t Gonna Need It)

  • 不要过度设计,不要去设计当前用不到的功能;不要去编写当前用不到的代码。
  • 但是要保留代码的扩展性。

DRY原则(Don’t Repeat Yourself)

提高代码的复用性,避免重复

迪米特法则(LOD, Law of Demeter)

又叫最小知识原则

每个模块(unit)只应该了解那些与它关系密切的模块(units: only units “closely” related to the current unit)的有限知识(knowledge)。或者说,每个模块只和自己的朋友“说话”(talk),不和陌生人“说话”(talk)。

设计模式与范式

创建型

单例模式

  • 优点:全局唯一
  • 缺点:单例对 OOP 特性的支持不友好;单例会隐藏类之间的依赖关系;单例对代码的扩展性不友好;单例对代码的可测试性不友好;单例不支持有参数的构造函数
class Singleton
{
public:
	static Singleton& instance()
	{
                // c++11 起
		static Singleton ins{};
		return ins;
	}

private:
	Singleton() = default;
	Singleton(const Singleton&) = delete;
	Singleton& operator=(const Singleton&) = delete;
};

上述的单例实现叫做进程唯一,即一个进程内都是同一个对象,但不同进程间是不同的对象。
与其对应的,还可以是线程唯一,我们可以通过哈希表将对象和线程ID进行映射来实现。
此外还可以是集群唯一,不同的进程间共享同一个对象,不能创建同一个类的多个对象,我们可以把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。

工厂模式

结构组成

  • 抽象工厂类: 提供创建产品的接口
  • 具体工厂类: 提供创建产品的实现
  • 抽象产品类: 提供产品的抽象
  • 具体产品类: 提供具体的产品功能
// 抽象产品类
class IProduct
{
public:
	virtual ~IProduct() = default;
	virtual void use() = 0;
};
// 抽象工厂类
class IFactory
{
public:
	virtual ~IFactory() = default;
	virtual void registerProduct(IProduct* product) = 0;
	virtual IProduct* createProduct(const std::string& owner) = 0;
	virtual IProduct* create(const std::string& owner) 
	{
		auto pro = createProduct(owner);
		registerProduct(pro);
		return pro;
	}
};
// 具体产品类
class Card : public IProduct
{
public:
	Card(const std::string& owner) : m_owner(owner) {}
	virtual void use() override
	{
		std::cout << "use card with " << m_owner << "\n";
	}
private:
	std::string m_owner;
};
// 具体工厂类
class CardFactory : public IFactory
{
public:
	virtual void registerProduct(IProduct* product) override
	{
		m_products.push_back(product);
	}
	virtual IProduct* createProduct(const std::string& owner) override
	{
		return new Card(owner);
	}
private:
	std::vector<IProduct*> m_products;
};

建造者模式

一个类的构造参数过多,或者一些参数是可选的,就可以采用建造者模式

如果一个类中有很多属性,为了避免构造函数的参数列表过长,影响代码的可读性和易用性,我们可以通过构造函数配合 set() 方法来解决。但是,如果存在下面情况中的任意一种,我们就要考虑使用建造者模式了。

  • 我们把类的必填属性放到构造函数中,强制创建对象的时候就设置。如果必填的属性有很多,把这些必填属性都放到构造函数中设置,那构造函数就又会出现参数列表很长的问题。如果我们把必填属性通过 set() 方法设置,那校验这些必填属性是否已经填写的逻辑就无处安放了。
  • 如果类的属性之间有一定的依赖关系或者约束条件,我们继续使用构造函数配合 set() 方法的设计思路,那这些依赖关系或约束条件的校验逻辑就无处安放了。
  • 如果我们希望创建不可变对象,也就是说,对象在创建好之后,就不能再修改内部的属性值,要实现这个功能,我们就不能在类中暴露 set() 方法。构造函数配合 set() 方法来设置属性值的方式就不适用了。
// 抽象建造者类
class IBuilder
{
public:
    virtual ~IBuilder() {}
    virtual void makeTitle(const std::string &title) = 0;
    virtual void makeString(const std::string &str) = 0;
    virtual void makeItems(const std::vector<std::string> &items) = 0;
    virtual void close() = 0;
};
// 具体建造者类
class TextBuilder : public IBuilder
{
public:
    void makeTitle(const std::string &title) override
    {
        m_str.append("============================\n");
        m_str.append("[" + title + "]\n");
        m_str.append("\n");
    }
    void makeString(const std::string &str) override
    {
        m_str.append(" * " + str + "\n");
        m_str.append("\n");
    }
    void makeItems(const std::vector<std::string> &items) override
    {
        for (auto item : items)
        {
            m_str.append(" .  " + item + "\n");
        }
        m_str.append("\n");
    }
    void close() override
    {
        m_str.append("============================\n");
    }
    std::string getResult() const
    {
        return m_str;
    }

private:
    std::string m_str;
};
// 统一建造类
class Director
{
public:
    Director(IBuilder *builder) : m_builder(builder) {}
    void construct()
    {
        m_builder->makeTitle("Greeting");
        m_builder->makeString("从早上到下午");
        m_builder->makeItems(std::vector<std::string>{ "早上好", "下午好" });
        m_builder->makeString(u8"晚上");
        m_builder->makeItems(std::vector<std::string>{ "晚上好", "晚安", "再见" });
        m_builder->close();
    }

private:
    IBuilder *m_builder;
};

原型模式

如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式,来创建新对象,以达到节省创建时间的目的。这种基于原型来创建对象的方式就叫作原型设计模式,简称原型模式。

原型模式有两种实现方法,深拷贝和浅拷贝。浅拷贝只会复制对象中基本数据类型数据和引用对象的内存地址,不会递归地复制引用对象,以及引用对象的引用对象……而深拷贝得到的是一份完完全全独立的对象。所以,深拷贝比起浅拷贝来说,更加耗时,更加耗内存空间。如果要拷贝的对象是不可变对象,浅拷贝共享不可变对象是没问题的,但对于可变对象来说,浅拷贝得到的对象和原始对象会共享部分数据,就有可能出现数据被修改的风险,也就变得复杂多了。比如需要从数据库中加载 10 万条数据并构建散列表索引,操作非常耗时,这种情况下比较推荐使用浅拷贝,否则,没有充分的理由,不要为了一点点的性能提升而使用浅拷贝。

结构型

代理模式

在某些情况下,一个客户不想或者不能直接引用一个对 象,此时可以通过一个称之为“代理”的第三者来实现 间接引用。代理对象可以在客户端和目标对象之间起到 中介的作用,并且可以通过代理对象去掉客户不能看到 的内容和服务或者添加客户需要的额外服务。

  • 如果有统一的接口,我们可以通过继承接口类实现相同的功能,其中原始类只负责业务代码,代理类负责在业务代码执行前后添加其他逻辑,并通过委托的方式调用原始类来执行业务代码
// 接口类
class Printable
{
public:
    virtual ~Printable() {}
    virtual void setPrinterName(const std::string &name) = 0;
    virtual const std::string &getPrinterName() const = 0;
    virtual void print() = 0;
};
// 原始类
class Printer : public Printable
{
public:
    Printer(const std::string &name) : m_name(name)
    {
    }
    virtual void setPrinterName(const std::string &name) override
    {
        m_name = name;
    }
    virtual const std::string &getPrinterName() const
    {
        return m_name;
    }
    void print() override
    {
        std::cout << __FUNCTION__ << getPrinterName() << std::endl;
    }

private:
    std::string m_name;
};
// 代理类
class PrinterProxy : public Printable
{
public:
    PrinterProxy(const std::string &name) : m_name(name) {}
    ~PrinterProxy()
    {
        delete m_printer;
    }
    virtual void setPrinterName(const std::string &name) override
    {
        if (m_printer != nullptr)
        {
            m_printer->setPrinterName(name);
        }
        m_name = name;
    }
    virtual const std::string &getPrinterName() const override
    {
        return m_name;
    }
    void print() override
    {
        if (nullptr == m_printer)
        {
            m_printer = new Printer(m_name);
        }
        m_printer->print();
    }

private:
    Printer *m_printer = nullptr;
    std::string m_name;
};
  • 假如没有统一接口,原始类是一个第三方库,我们可以通过继承原始类实现代理类,这里需要注意:如果原始类的函数是虚函数,并且不同的虚函数间有调用关系,那么继承后重写了虚函数逻辑可能会有不符合预期的结果,当然除了继承的方式还可以使用组合。
// 原始类
class Printer
{
public:
    Printer(const std::string& name) : m_name(name)
    {
    }
    void setPrinterName(const std::string& name)
    {
        m_name = name;
    }
    const std::string& getPrinterName()
    {
        return m_name;
    }
    void print()
    {
        std::cout << __FUNCTION__ << getPrinterName() << std::endl;
    }

private:
    std::string m_name;
};
// 代理类
class PrinterProxy : public Printer
{
public:
    PrinterProxy(const std::string& name) : Printer(name), m_nameproxy(name + "proxy") {}

    void setPrinterName(const std::string& name)
    {
        Printer::setPrinterName(name);
        m_nameproxy = name + "proxy";
    }
    const std::string& getPrinterName()
    {
        return m_nameproxy;
    }
    void print()
    {
        Printer::print();
        std::cout << __FUNCTION__ << getPrinterName() << std::endl;
    }

private:
    std::string m_nameproxy;
};

桥接模式

用于分离 类的功能层次结构 和 类的实现层次结构。假设现在需要n种功能,m种实现,如果采用继承的方式,那就需要实现 n*m 个类;但是采用功能和实现分离,那就可以分别选一种功能和一种实现进行组合,这样只需要实现 m + n 个类

// 类的功能1
class Display
{
public:
    virtual ~Display() {}
    Display(IDisplayImpl *impl) : m_pImpl(impl) {}
    virtual void open()
    {
        m_pImpl->rawOpen();
    }
    virtual void print()
    {
        m_pImpl->rawPrint();
    }
    virtual void close()
    {
        m_pImpl->rawClose();
    }
    void display()
    {
        open();
        print();
        close();
    }

private:
    IDisplayImpl *m_pImpl;
};
// 类的功能2
class CountDisplay : public Display
{
public:
    using Display::Display;
    void mulitDisplay(unsigned int count)
    {
        open();
        for (unsigned int i = 0; i < count; ++i)
        {
            print();
        }
        close();
    }
};

// 类的实现接口
class IDisplayImpl
{
public:
    virtual ~IDisplayImpl() {}
    virtual void rawOpen() = 0;
    virtual void rawPrint() = 0;
    virtual void rawClose() = 0;
};
// 类的实现1:
class StringDisplayImpl : public IDisplayImpl
{
public:
    StringDisplayImpl(const std::string &str) : m_str(str) {}
    virtual void rawOpen() override
    {
        printLine();
    }
    virtual void rawPrint() override
    {
        cout << "|" << m_str << "|" << std::endl;
    }
    virtual void rawClose() override
    {
        printLine();
    }

private:
    void printLine()
    {
        cout << "+";
        for (int i = 0; i < m_str.length(); ++i)
        {
            cout << "-";
        }
        cout << "+" << endl;
    }

private:
    std::string m_str;
};

装饰器模式

装饰器模式主要解决继承关系过于复杂的问题,通过组合来替代继承。它主要的作用是给原始类添加增强功能。这也是判断是否该用装饰器模式的一个重要的依据。除此之外,装饰器模式还有一个特点,那就是可以对原始类嵌套使用多个装饰器。为了满足这个应用场景,在设计的时候,装饰器类需要跟原始类继承相同的抽象类或者接口。

和代理模式差不多

适配器模式

在软件开发中采用类似于电源适配器的设计和编码技巧被称为适配器模式。
通常情况下,客户端可以通过目标类的接口访问它所提供的服务。有时,现有的类可以满足客户类的功能需要,但是它所提供的接口不一定是客户类所期望的,这可能是因为现有类中方法名与目标类中定义的方法名不一致等原因所导致的。
在这种情况下,现有的接口需要转化为客户类期望的接口,这样保证了对现有类的重用。如果不进行这样的转化,客户类就不能利用现有类所提供的功能,适配器模式可以完成这样的转化。
在适配器模式中可以定义一个包装类,包装不兼容接口的对象,这个包装类指的就是适配器(Adapter),它所包装的对象就是适配者(Adaptee),即被适配的类。
适配器提供客户类需要的接口,适配器的实现就是把客户类的请求转化为对适配者的相应接口的调用。也就是说:当客户类调用适配器的方法时,在适配器类的内部将调用适配者类的方法,而这个过程对客户类是透明的,客户类并不直接访问适配者类。因此,适配器可以使由于接口不兼容而不能交互的类可以一起工作。这就是适配器模式的模式动机。

就是把一个类包装一下

门面模式/外观模式

门面模式为子系统提供一组统一的接口,定义一组高层接口让子系统更易用。
应用场景

  • 解决易用性问题
  • 解决性能问题
    // 三个方法a, b, c
    // 前端的一个操作需要 先后调用 a->b->c
    // 每次调用就是一次网络通讯
    // 后端可以将a, b, c三个函数再封装成一个新函数d, 这样前端只需一次网络通讯
    void d() {
        a();
        b();
        c();
    } 
    
  • 解决分布式事务问题
    // 两个方法a, b
    // 前端一个操作,需要先后调用 a->b, 并且 a b 两个函数必须同时成功或者同时失败
    // 后端利用程序框架提供的事务封装 a b 函数, 即可解决
    

组合模式

组合模式,将一组对象组织成树形结构,将单个对象和组合对象都看做树中的节点,以统一处理逻辑,并且它利用树形结构的特点,递归地处理每个子树,依次简化代码实现。使用组合模式的前提在于,你的业务场景必须能够表示成树形结构。所以,组合模式的应用场景也比较局限,它并不是一种很常用的设计模式。
比如Qt的QMenu和QAction,都可以看成一个节点,并组合成一个树形的结构。

享元模式

享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象。实际上,它的代码实现非常简单,主要是通过工厂模式,在工厂类中,通过一个 Map 来缓存已经创建过的享元对象,来达到复用的目的。
比如字体库,提供了有限的字体符号,每个引用它的地方只需标记索引即可。
再比如大文本存储,假如这个文本中共有100类节点名,有100万个地方按照"节点名" : data这种方式存储数据,由于每个节点名是一个字符串,占用很大空间,我们可以先存储这100类节点名并生成唯一的id,后续的100万个地方只填索引,占用空间就能大大缩减了。

行为型

观察者模式

参考

图说设计模式
设计模式之美-王争

posted @ 2022-12-06 16:18  miyanyan  阅读(70)  评论(0编辑  收藏  举报