SOLID设计原则

SOLID是以下设计原则的缩写,具体(包括缩写) 如下 :

  • 单一责任原则(SRP)
  • 开闭原则(OCP)
  • 里斯科夫替代原则(LSP)
  • 接口隔离原则(ISP)
  • 依赖倒置原则 (DIP)

这些原则是罗伯特·C·马丁(Robert C. Martin)在2000年代初期提出的。实际上,它们只是从罗伯特(Robert)的书和博客中表达的数十种原则中选出的五种。 这五个特定主题通常会渗透到模式和软件设计的讨论中,因此在我们深入研究设计模式(我知道你们都非常渴望)之前,我们将简要回顾一下SOLID原则的全部含义。

单一责任原则

假设您决定保留最亲密的想法的日记。 该日记有标题和许多条目。 您可以按以下方式对其进行建模:

struct Journal
{
  string title;
  vector<string> entries;
  explicit Journal(const string& title) : title{title} {}
};

现在,您可以添加“将条目添加到日记”的功能,并在日记中添加条目的序号作为前缀。 这很容易:

void Journal::add(const string& entry)
{
  static int count = 1;
  entries.push_back(boost::lexical_cast<string>(count++) + ": " + entry);
}

该日记现在可以用作:

Journal j{"Dear Diary"};
j.add("I cried today");
j.add("I ate a bug");

将此功能作为Journal类的一部分是有意义的,因为添加日记条目是日记类实际需要执行的操作。 保留条目是日记的责任,因此与此相关的任何事情都是应该做的。

现在,假设您决定通过将日记保存在文件中来保留日记。 您将此代码添加到Journal类:

void Journal::save(const string& filename)
{
  ofstream ofs(filename);
  for (auto& s : entries)
    ofs << s << endl;
}

这种方法是有问题的。 日记的责任是保留日记条目,而不是将其写入磁盘。 如果将磁盘写入功能添加到Journal和类似类中,则持久性方法的任何更改(例如,您决定写入云服务而不是磁盘)都将需要在每个受影响的类中进行许多细微更改。

我想在这里停留一下并提出一个要点:一种体系结构,使您不得不对丢失的类进行很多细微的更改,无论它们是否相关(如在层次结构中),通常都是“代码异味”,即表示某些事情并非正确。 现在,这实际上取决于现实环境:如果您要重命名在一百个地方使用的符号,我认为这通常是可以的,因为ReSharper,CLion或您使用的任何IDE实际上都可以让您执行重构并让这种变化传播到任何位置。但是,当您需要完全重新设计界面时……好吧,这可能是一个非常痛苦的过程!

因此,我指出持久性是一个单独的问题,最好在一个单独的类中表达它,例如:

struct PersistenceManager
{
  static void save(const Journal& j, const string& filename)
  {
    ofstream ofs(filename);
    for (auto& s : j.entries)
      ofs << s << endl;
  }
};

这正是“单一责任”的含义:每个类只有一个责任,因此只有一个改变的理由,即仅在条目存储方面需要做更多事情的情况下,日记才需要更改。例如,您可能希望每个条目都带有时间戳前缀,因此您可以更改add()函数来做到这一点。另一方面,如果要更改持久性机制,则可以在PersistenceManager中进行更改。

违反SRP的反模式的极端示例称为“上帝对象”。 God Object是一个庞大的类,它试图处理尽可能多的问题,成为一个很难处理的整体怪兽。

对我们来说幸运的是,God Objects易于识别,并且由于有源代码控制系统(仅计算成员函数的数量),可以迅速识别负责任的开发人员并对其进行适当的惩罚。

开闭原则

假设我们在数据库中有一个(完全假设的)产品范围。 每个产品都有颜色和大小,并定义为:

enum class Color { Red, Green, Blue };
enum class Size { Small, Medium, Large };

struct Product
{
  string name;
  Color color;
  Size size;
};

现在,我们想为给定的产品集提供某些过滤功能。 我们制作一个类似于以下内容的过滤器:

struct ProductFilter
{
  typedef vector<Product*> Items;
};

现在,为了支持按颜色过滤产品,我们定义一个成员函数来精确地做到这一点:

ProductFilter::Items ProductFilter::by_color(Items items, Color color)
{
  Items result;
  for (auto& i : items)
    if (i->color == color)
      result.push_back(i);
  return result;
}

我们当前按颜色过滤项目的方法都很好。 我们的代码投入生产,但不幸的是,一段时间之后,老板进来了,并要求我们也按大小实施过滤。 因此,我们跳回到ProductFilter.cpp,添加以下代码并重新编译:

ProductFilter::Items ProductFilter::by_size(Items items, Size size)
{
  Items result;
  for (auto& i : items)
    if (i->size == size)
      result.push_back(i);
  return result;
}

感觉像是完全重复,不是吗? 为什么我们不编写一个带有谓词(某些函数)的通用方法呢? 嗯,一个原因可能是可以用不同的方式完成不同形式的过滤:例如,某些记录类型可能已被索引并且需要以特定方式进行搜索; 有些数据类型适合在GPU上搜索,而另一些则不能。

我们的代码已投入生产,但是老板再次回来告诉我们,现在需要同时按颜色和尺寸进行搜索。 那么我们该怎么做,但要添加另一个功能?

ProductFilter::Items ProductFilter::by_color_and_size(Items
  items, Size size, Color color)
{
  Items result;
  for (auto& i : items)
    if (i->size == size && i->color == color)
      result.push_back(i);
  return result;
}

从前面的场景中,我们想要的是实现开放式封闭原则,该原则指出类型为扩展而开放,而为修改而封闭。 换句话说,我们想要可扩展的过滤(也许在不同的编译单元中),而不必修改它(并重新编译已经起作用并且可能已经交付给客户的东西)。

我们如何实现呢? 好吧,首先,我们从概念上将过滤过程分为两部分:一个过滤器(一个处理所有项,只返回一些项的过程)和一个规范(适用于数据元素的谓词的定义) 。

我们可以对规范接口进行非常简单的定义:

template <typename T> struct Specification
{
  virtual bool is_satisfied(T* item) = 0;
};

在前面的示例中,类型T是我们选择的类型:它当然可以是产品,但也可以是其他类型。 这使得整个方法可重复使用。

接下来,我们需要一种基于Specification<T>的过滤方式:您可以通过定义一个Filter<T>来实现:

template <typename T> struct Filter
{
  virtual vector<T*> filter(
    vector<T*> items,
    Specification<T>& spec) = 0;
};

同样,我们要做的只是为称为filter的函数指定签名,该函数接受所有项目和一个规范,并返回符合该规范的所有项目。 假设将项目存储为vector<T*>,但实际上,您可以将 filter() 传递给一对迭代器或一些专门为通过集合而设计的定制接口。 遗憾的是,C++语言未能使枚举或集合的概念标准化,而其他编程语言(例如.NET的IEnumerable)中却存在这种枚举或集合。

基于上述内容,改进的过滤器的实现非常简单:

struct BetterFilter : Filter<Product>
{
  vector<Product*> filter(
    vector<Product*> items,
    Specification<Product>& spec) override 
  {
    vector<Product*> result;
    for (auto& p : items)
      if (spec.is_satisfied(p))
        result.push_back(p);
    return result;
  }
};

同样,您可以将Specification<T>视为与std :: function等仅适用于一定数量过滤器规格的强类型等效传递参数。

现在,这部分比较简单。 要编写颜色过滤函数,您需要创建一个ColorSpecification:

struct ColorSpecification : Specification<Product>
{
  Color color;
  explicit ColorSpecification(const Color color) : color{color} {}
  bool is_satisfied(Product* item) override {
    return item->color == color;
  }
};

有了此规范,并给出了产品列表,我们现在可以按以下方式过滤它们:

Product apple{ "Apple", Color::Green, Size::Small };
Product tree{ "Tree", Color::Green, Size::Large };
Product house{ "House", Color::Blue, Size::Large };

vector<Product*> all{ &apple, &tree, &house };

BetterFilter bf;
ColorSpecification green(Color::Green);

auto green_things = bf.filter(all, green);
for (auto& x : green_things)
  cout << x->name << " is green" << endl;

前面的代码使我们获得“ Apple”和“ Tree”,因为它们都是绿色的。 现在,到目前为止,我们还没有实现的唯一方法就是搜索尺寸和颜色(或者实际上是说明了如何搜索尺寸或颜色,或者混合使用不同的条件)。 答案是您只需制定一个复合规范。 例如,对于逻辑与,您可以使其如下:

template <typename T> struct AndSpecification : Specification<T>
{
  Specification<T>& first;
  Specification<T>& second;

  AndSpecification(Specification<T>& first, Specification<T>& second)
    : first{first}, second{second} {}

  bool is_satisfied(T* item) override 
  {
    return first.is_satisfied(item) && second.is_satisfied(item);
  }
};

现在,您可以根据更简单的规格自由创建复合条件。 重复使用之前定义的 green 规范,找到绿色又大的东西变得简单了:

SizeSpecification large(Size::Large);
ColorSpecification green(Color::Green);
AndSpecification<Product> green_and_large{ large, green };

auto big_green_things = bf.filter(all, green_and_big);
for (auto& x : big_green_things)
  cout << x->name << " is large and green" << endl;

// Tree is large and green

有很多代码对吧!但是请记住,由于C++的强大功能,您可以简单地为两个Specification 对象引入一个运算符&&,从而使按两个(或多个!)条件进行过滤的过程变得非常简单:

template <typename T> struct Specification
{
  virtual bool is_satisfied(T* item) = 0;
  AndSpecification<T> operator &&(Specification&& other)
  {
    return AndSpecification<T>(*this, other);
  }
};

如果现在避免为尺寸/颜色规格添加额外的变量,则可以将复合规格简化为单行:

auto green_and_big = ColorSpecification(Color::Green) && SizeSpecification(Size::Large);

因此,让我们回顾一下开闭原则的原理是什么以及前面的示例是如何实施的。 基本上,开闭原则指出,您无需返回到已经编写和测试过的代码并进行更改。 这就是这里正在发生的事情! 我们制作了Specification<T>和Filter<T>,然后,我们要做的就是实现其中一个接口(无需自行修改接口)以实现新的过滤机制。 这就是“为扩展而打开,为修改而关闭”的含义。

里斯科夫替代原则

以Barbara Liskov的名字命名的斯科夫替代原则指出,如果接口采用类型为Parent的对象,则它同样应采用类型为Child的对象,而不会发生任何中断。让我们看一下斯科夫替代原则中断的情况。

这是一个矩形; 它具有宽度和高度,并有一堆getter和setter来计算面积:

class Rectangle
{
protected:
  int width, height;
public:
  Rectangle(const int width, const int height) : width{width}, height{height} { }

  int get_width() const { return width; }
  virtual void set_width(const int width) { this->width = width; }
  int get_height() const { return height; }
  virtual void set_height(const int height) { this->height = height; }
  int area() const { return width * height; }
};

现在,假设我们制作了一种特殊的矩形,称为正方形。 此对象将覆盖setter以设置宽度和高度:

class Square : public Rectangle
{
public:
  Square(int size): Rectangle(size,size) {}
  void set_width(const int width) override {
    this->width = height = width;
  }
  void set_height(const int height) override {
    this->height = width = height;
  }
};

这种方法是邪恶的。 您还看不到它,因为它确实看起来很无辜:setter只是设置两个尺寸,可能出什么问题了吗? 好吧,如果采用上述方法,我们可以轻松地构造一个采用Rectangle的函数,该Rectangle在采用正方形时会产生意外:

void process(Rectangle& r)
{
  int w = r.get_width();
  r.set_height(10);
  cout << "expected area = " << (w * 10) << ", got " << r.area() << endl;
}

前面的函数将公式Area = Width×Height不变。该公式要获取宽度,设置高度,并正确地计算出了期望面积。但是用Square调用前面的函数会导致不匹配:

Square s{5};
process(s); // expected area = 50, got 25

这个例子(我承认是有些人为的)通过process()得到面积值,无法完全采用派生类型Square却采用了基本类型Rectangle,这就破坏了里斯科夫替代原则。 如果您将它作为Rectangle输入,则一切都很好,因此可能需要一些时间才能在测试(或生产中,希望不是问题)上发现问题。

有什么解决方案? 好吧,有很多。 就个人而言,我认为Square类型甚至不应该存在:相反,我们可以创建一个同时创建矩形和正方形的Factory:

struct RectangleFactory
{
  static Rectangle create_rectangle(int w, int h);
  static Rectangle create_square(int size);
};

您可能还需要一种检测矩形实际上是正方形的方法:

bool Rectangle::is_square() const
{
  return width == height;
}

在这种情况下,最明智的选择是在Square的set_width()/set_height()中引发一个异常,指出不支持这些操作,而应该使用set_size()。 但是,这违反了最少保留的原则,因为您希望调用set_width()进行有意义的更改……对吗?

接口隔离原则

好的,这是另一个适合于说明问题的示例。 假设您决定定义一个多功能打印机:可以打印、扫描和传真文档的设备。 因此,您可以这样定义它:

struct MyFavouritePrinter /* : IMachine */
{
  void print(vector<Document*> docs) override;
  void fax(vector<Document*> docs) override;
  void scan(vector<Document*> docs) override;
};

这很好。 现在,假设您决定定义一个接口,所有打算生产多功能打印机的人都必须实现此接口。 因此,您可以在喜欢的IDE中使用“提取接口”功能,并且会得到如下所示的内容:

struct IMachine
{
  virtual void print(vector<Document*> docs) = 0;
  virtual void fax(vector<Document*> docs) = 0;
  virtual void scan(vector<Document*> docs) = 0;
};

这是个问题。 这是个问题的原因是该接口的某些实现者可能不需要扫描或传真,而只需要打印即可。 但是,您正在强迫他们实施这些额外的功能:当然,它们都可以是无操作的,但是为什么要为此烦恼呢?

因此,接口隔离原则建议您拆分接口,以便实施者可以根据自己的需要进行选择。 由于打印和扫描是不同的操作(例如,扫描仪无法打印),因此我们为这些定义了单独的接口:

struct IPrinter
{
  virtual void print(vector<Document*> docs) = 0;
};

struct IScanner
{
  virtual void scan(vector<Document*> docs) = 0;
};

然后,打印机和扫描仪可以仅实现所需的功能:

struct Printer : IPrinter
{
  void print(vector<Document*> docs) override;
};

struct Scanner : IScanner
{
  void scan(vector<Document*> docs) override;
};

现在,如果我们确实需要IMachine接口,则可以将其定义为上述接口的组合:

struct IMachine: IPrinter, IScanner /* IFax and so on */
{
};

当您要在具体的多功能设备中实现此接口时,这就是要使用的接口。 例如,您可以使用简单的委托(delegation)来确保Machine重用特定IPrinter和IScanner提供的功能:

struct Machine : IMachine
{
  IPrinter& printer;
  IScanner& scanner;

  Machine(IPrinter& printer, IScanner& scanner) : printer{printer}, scanner{scanner}
  {
  }

  void print(vector<Document*> docs) override {
    printer.print(docs);
  }

  void scan(vector<Document*> docs) override
  {
    scanner.scan(docs);
  }
};

因此,回顾一下,这里的想法是将复杂接口的各个部分隔离到单独的接口中,以避免强迫实现者实现他们真正不需要的功能。 每当您为某些复杂的应用程序编写插件时,就会获得带有20个令人困惑的函数的接口,以实现各种无操作运算并返回nullptr,这很可能是API作者违反了ISP。

依赖注入原则

依赖注入原则的原始定义如下:

一、高级模块不应依赖于低级模块。 两者都应依赖抽象。

该语句的基本含义是,如果您对日志记录感兴趣,则报告组件不应依赖于具体的ConsoleLogger,而可以依赖于ILogger接口。 在这种情况下,我们认为报告组件是高级的(更接近业务领域),而日志记录则是一个基本的(有点像文件I/O或线程,但不完全是)低级模块。

二、抽象不应依赖细节。 细节应取决于抽象。

再次重申,对接口或基类的依赖比对具体类型的依赖要好。希望此声明的真实性是显而易见的,因为这种方法支持更好的可配置性和可测试性,前提是您使用的是好的框架来为您处理这些依赖性。

所以现在的主要问题是:您如何实际实现所有上述功能? 当然,这还需要做很多工作,因为现在您需要明确声明,例如,报告依赖于ILogger。 表达方式可能如下:

class Reporting
{
  ILogger& logger;
public:
  Reporting(const ILogger& logger) : logger{logger} {}
  void prepare_report()
  {
    logger.log_info("Preparing the report");
    ...
  }
}

现在的问题是,要初始化前面的类,您需要显式调用Reporting(ConsoleLogger{}}或类似的东西。 如果报告类依赖于五个不同的接口怎么办? 如果ConsoleLogger具有自己的依赖关系怎么办? 您可以通过编写许多代码来进行管理,但是有更好的方法。

执行上述操作的现代、新潮、时尚的方式是使用依赖注入:这实际上意味着您使用Boost.DI之类的库来自动满足特定组件的依赖要求。

让我们考虑一个具有引擎但也需要写入日志的汽车的示例。 就目前而言,我们可以说汽车取决于这两个方面。 首先,我们可以将引擎定义为:

struct Engine
{
  float volume = 5;
  int horse_power = 400;

  friend ostream& operator<< (ostream& os, const Engine& obj)
  {
    return os << "volume: " << obj.volume << " horse_power: " << obj.horse_power;
  } // thanks, ReSharper!
};

现在,由我们决定是否要提取IEngine接口并将其提供给汽车。也许我们会,也许我们不会,这通常是设计决定。 如果您打算拥有一个引擎层次结构,或者预见需要一个NullEngine进行测试,那么可以,您确实需要抽象出这些接口。

无论如何,我们还希望进行日志记录,并且由于可以通过多种方式(控制台,电子邮件,SMS,鸽子邮件等等)完成日志记录,因此我们可能希望拥有ILogger接口:

struct ILogger
{
  virtual ~ILogger() {}
  virtual void Log(const string& s) = 0;
};

以及某种具体的实现:

struct ConsoleLogger : ILogger
{
  ConsoleLogger() {}

  void Log(const string& s) override
  {
    cout << "LOG: " << s.c_str() << endl;
  }
};

现在,我们要定义的汽车取决于引擎和记录组件。 我们两者都需要,但是如何存储它们实际上取决于我们:我们可以使用指针,引用,unique_ptr / shared_ptr或其他东西。 我们将两个相关组件都定义为构造函数参数:

struct Car
{
  unique_ptr<Engine> engine;
  shared_ptr<ILogger> logger;

  Car(unique_ptr<Engine> engine,
  const shared_ptr<ILogger>& logger) : engine{move(engine)}, logger{logger}
  {
    logger->Log("making a car");
  }

  friend ostream& operator<<(ostream& os, const Car& obj)
  {
    return os << "car with engine: " << *obj.engine;
  }
};

现在,您可能希望在初始化Car时看到make_unique / make_shared调用。但是我们不会做任何事情。 相反,我们将使用Boost.DI。 首先,我们将定义将ILogger绑定到ConsoleLogger的绑定关系; 基本上,这意味着“任何时候有人要求ILogger给他们一个ConsoleLogger”:

auto injector = di::make_injector(
  di::bind<ILogger>().to<ConsoleLogger>()
);

现在我们已经配置了注入函数,我们可以使用它来制造汽车:

auto car = injector.create<shared_ptr<Car>>();

前面的代码创建了一个shared_ptr ,它指向完全初始化的Car对象,这正是我们想要的。 这种方法的优点在于,要更改所使用的记录器的类型,我们可以在单个位置(绑定调用)进行更改,现在,出现ILogger的每个位置都可以使用我们提供的其他日志记录组件。 这种方法还有助于我们进行单元测试,并允许我们使用存根(或Null Object模式)代替模拟。

设计模式时间到了!

了解了SOLID设计原理后,我们准备看一下设计模式本身。 束缚自己; 这将是一个漫长的(但希望不会很无聊)的旅程!

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据