基本原则
一言概之
- 单一职责原则 (Single Responsibility Principle)
就一个类而言,应该仅有一个引起它变化的原因。
- 开放-关闭原则 (Open-Closed Principle)
软件实体(类、模块、函数等)应该对扩展开放,对修改关闭
- 里氏替换原则 (Liskov Substitution Principle)
子类可以扩展父类的功能,但不能改变父类原有的功能
- 依赖倒转原则 (Dependence Inversion Principle)
面向接口编程
- 接口隔离原则 (Interface Segregation Principle)
一个类对另一个类的依赖应该建立在最小的接口上,
不应强迫客户依赖他们不使用的接口
- 迪米特法则(Law Of Demeter)
一个类对于其他类知道的越少越好
- 组合/聚合复用原则 (Composite/Aggregate Reuse Principle)
组合或者聚合好过于继承。
SOLID原则
S RP – 单一职责原则
O CP – 开/关原则
L SP – Liskov 替换原则
I SP – 接口隔离原则
D IP – 依赖倒转原则
食果去皮
单一职责原则
定义
一个类改变的原因不应该超过一个。一个类应该专注于一个单一的功能,解决一个特定的问题。
示例
例如,考虑一个编译和打印报告的模块。假设可以基于两个原因更改这样的模块。首先,报告的内容可能会发生变化。第二,报告的格式可能会改变。这两件事因不同的原因而改变。单责任原则认为问题的这两个方面实际上是两个独立的责任,因此应该放在独立的类或模块中。将两个在不同时间因不同原因而改变的东西结合在一起是一个糟糕的设计。 1
示例-违反原则的示例
class Journal {
string m_title;
vector<string> m_entries;
public:
explicit Journal(const string &title) : m_title{title} {}
void add_entries(const string &entry) {
static uint32_t count = 1;
m_entries.push_back(to_string(count++) + ": " + entry);
}
auto get_entries() const { return m_entries; }
void save(const string &filename) {
ofstream ofs(filename);
for (auto &s : m_entries) ofs << s << endl;
}
};
int main() {
Journal journal{"Dear XYZ"};
journal.add_entries("I ate a bug");
journal.add_entries("I cried today");
journal.save("diary.txt");
return EXIT_SUCCESS;
}
- 上面的c++例子看起来很好,只要你有一个单一的领域对象,例如Journal。但在实际应用程序中通常不是这样。
- 当我们开始添加像Book, File等域对象时,你必须为每个人分别实现保存方法,这不是实际的问题。
- 当您必须更改或维护保存功能时,真正的问题就出现了。例如,有一天你将不再保存数据文件和采用的数据库。在这种情况下,你必须遍历每个域对象实现&需要修改所有代码,这是不好的。
- 在这里,我们违反了单一责任原则,为Journal类提供了更改的两个原因,即:
- 与期刊相关的事情
- 保存杂志
- 此外,代码也会变得重复、臃肿和难以维护。
解决方法
class Journal {
string m_title;
vector<string> m_entries;
public:
explicit Journal(const string &title) : m_title{title} {}
void add_entries(const string &entry) {
static uint32_t count = 1;
m_entries.push_back(to_string(count++) + ": " + entry);
}
auto get_entries() const { return m_entries; }
//void save(const string &filename)
//{
// ofstream ofs(filename);
// for (auto &s : m_entries) ofs << s << endl;
//}
};
struct SavingManager {
static void save(const Journal &j, const string &filename) {
ofstream ofs(filename);
for (auto &s : j.get_entries())
ofs << s << endl;
}
};
SavingManager::save(journal, "diary.txt");
- 日志应该只处理条目和与日志相关的事情。
- 应该有一个独立的中心位置或实体来做拯救的工作。在我们的例子中,它是SavingManager。
- 随着SavingManager的增长,所有与存储相关的代码都将放在一个地方。您还可以对它进行模板化以接受更多的域对象。
好处
- 表现力
- 当类只做一件事时,它的接口通常有少量的方法,更具表现力。 因此,它也有少量的数据成员。
- 这提高了您的开发速度,并使您作为软件开发人员的生活更加轻松。
- 可维护性
- 我们都知道需求会随着时间而变化,设计/架构也是如此。 你的class的责任越多,你就越需要改变它。 如果你的类实现了多个职责,它们就不再相互独立。
- 独立的更改减少了软件其他不相关区域的中断。
- 由于编程错误与复杂性成反比,因此更容易理解使代码不易出现错误并更易于维护。
- 可重用性
- 如果一个类有多个职责,并且在软件的另一个领域只需要其中一个,那么其他不必要的职责会阻碍可重用性。
- 具有单一职责意味着该类应该是可重用的,无需或更少修改。
开闭原则
定义
Open for extension – This means that the behavior of the module can be extended. As the requirements of the application change, we are able to extend the module with new behaviors that satisfy those changes. In other words, we are able to change what the module does.
Closed for modification – Extending the behavior of a module does not result in changes to the source or binary code of the module. The binary executable version of the module, whether in a linkable library, a DLL, or a Java .jar, remains untouched.
- 对扩展是开放的,意味着软件实体的行为是可扩展的,当需求变更的时候,可以对模块进行扩展,使其满足需求变更的要求。
- 对修改是关闭的,意味着当对软件实体进行扩展的时候,不需要改动当前的软件实体;不需要修改代码;对于已经完成的类文件不需要重新编辑;对于已经编译打包好的模块,不需要再重新编译。
两者结合起来表述为:添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。2
如何设计
例外情况
在极少数情况下,代码的修改是绝对必要的,而且无法避免。
- 其中一个例子就是模块中存在的缺陷。在修复缺陷的情况下,允许更改模块代码及其各自的测试用例。
- 允许对现有代码进行任何更改,只要它不需要对该代码的任何客户端进行更改。这使得模块版本可以通过新的语言特性进行升级。例如,Spring 5支持并使用Java 8 lambda语法,但要使用它,我们不需要更改我们的客户端应用程序代码。
如何设计
- 继承实现:
类是封闭的,因为它可以被编译、存储在库中、基线化,并被客户端类使用。但它也是开放的,因为任何新类都可以使用它作为父类,从而增加了新特性。定义子类时,不需要更改原始类或干扰其客户端。3 缺点:如果子类依赖于它们父类的实现细节,继承就会引入紧密耦合。
Design for inheritance or prohibit it. – Effective Java (Addison-Wesley, 2008), Joshua Bloch
- 抽象与组合实现(接口继承):
将开/闭原则重新定义为多态开/闭原则。它使用接口而不是超类来允许不同的实现,您可以轻松地替换这些实现,而无需更改使用它们的代码。这些接口对修改是关闭的,您可以提供新的实现来扩展软件的功能。好处是接口引入了支持松耦合的额外抽象级别。接口的实现是相互独立的,不需要共享任何代码。3
示例
计算面积
- 如果只有一个矩形的面积计算,那么我们可以这么写(违反OCP)
public class Rectangle
{
public double Width { get; set; }
public double Height { get; set; }
}
public class AreaCalculator
{
public double Area(Rectangle[] shapes)
{
double area = 0;
foreach (var shape in shapes)
{
area += shape.Width*shape.Height;
}
return area;
}
}
- 如果再加一个圆形面积,我们还可以这么写(违反OCP)
public double Area(object[] shapes)
{
double area = 0;
foreach (var shape in shapes)
{
if (shape is Rectangle)
{
Rectangle rectangle = (Rectangle) shape;
area += rectangle.Width*rectangle.Height;
}
else
{
Circle circle = (Circle)shape;
area += circle.Radius * circle.Radius * Math.PI;
}
}
return area;
}
- 但如果我们要再加一个三角形,我们就不得不好好思索下自己的架构是否合适了,写下如下代码,这样我们只需要添加一个三角形的类就行了,而不用修改原有的逻辑。(符合OCP)
//基类
public abstract class Shape
{
public abstract double Area();
}
//矩形面积计算
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public override double Area()
{
return Width*Height;
}
}
// 圆形面积计算
public class Circle : Shape
{
public double Radius { get; set; }
public override double Area()
{
return Radius*Radius*Math.PI;
}
}
//通用计算
public double Area(Shape[] shapes)
{
double area = 0;
foreach (var shape in shapes)
{
area += shape.Area();
}
return area;
}
水果示例
- 违反ocp原则
enum class COLOR { RED, GREEN, BLUE };//颜色红、绿、蓝
enum class SIZE { SMALL, MEDIUM, LARGE };//大小:小、中、大
struct Product {
string m_name;//名称
COLOR m_color;//颜色
SIZE m_size;//大小
};
using Items = vector<Product*>;//产品容器
#define ALL(C) begin(C), end(C)
struct ProductFilter {
//过滤颜色
static Items by_color(Items items, const COLOR e_color) {
Items result;
for (auto &i : items)
if (i->m_color == e_color)
result.push_back(i);
return result;
}
//过滤大小
static Items by_size(Items items, const SIZE e_size) {
Items result;
for (auto &i : items)
if (i->m_size == e_size)
result.push_back(i);
return result;
}
//过滤二者的组合
static Items by_size_and_color(Items items, const SIZE e_size, const COLOR e_color) {
Items result;
for (auto &i : items)
if (i->m_size == e_size && i->m_color == e_color)
result.push_back(i);
return result;
}
};
int main() {
const Items all{
new Product{"Apple", COLOR::GREEN, SIZE::SMALL},
new Product{"Tree", COLOR::GREEN, SIZE::LARGE},
new Product{"House", COLOR::BLUE, SIZE::LARGE},
};
for (auto &p : ProductFilter::by_color(all, COLOR::GREEN))
cout << p->m_name << " is green\n";
for (auto &p : ProductFilter::by_size_and_color(all, SIZE::LARGE, COLOR::GREEN))
cout << p->m_name << " is green & large\n";
return EXIT_SUCCESS;
}
/*
Apple is green
Tree is green
Tree is green & large
*/
- 我们有很多产品并且我们通过它的一些属性来过滤它。只要需求是固定的,上面的代码就没有什么问题(这在软件工程中永远不会是这样)。
- 但是请想象一下这种情况:您已经将代码发送给客户端。随后,需求变化和一些新的过滤器是必需的。在这种情况下,您再次需要修改类并添加新的筛选器方法。
- 这是一个有问题的方法,因为我们有两个属性(即颜色,大小),需要实现3个函数(即颜色,尺寸,其组合),多一个属性就需要实现8个函数
- 你需要一遍又一遍地在现有的实现代码并且必须修改它,这可能会破坏其他部分的代码。这不是一个可扩展的解决方案。
- 开放-关闭原则指出,你的系统应该对扩展开放,但对修改关闭。不幸的是,我们在这里做的是修改现有的代码,这是一个违反OCP的示例。
- 解决方法
- 添加可扩展的抽象级别
template <typename T>
struct Specification {
virtual ~Specification() = default;
virtual bool is_satisfied(T *item) const = 0;
};
struct ColorSpecification : Specification<Product> {
COLOR e_color;
ColorSpecification(COLOR e_color) : e_color(e_color) {}
bool is_satisfied(Product *item) const { return item->m_color == e_color; }
};
struct SizeSpecification : Specification<Product> {
SIZE e_size;
SizeSpecification(SIZE e_size) : e_size(e_size) {}
bool is_satisfied(Product *item) const { return item->m_size == e_size; }
};
template <typename T>
struct Filter {
virtual vector<T *> filter(vector<T *> items, const Specification<T> &spec) = 0;
};
struct BetterFilter : Filter<Product> {
vector<Product *> filter(vector<Product *> items, const Specification<Product> &spec) {
vector<Product *> result;
for (auto &p : items)
if (spec.is_satisfied(p))
result.push_back(p);
return result;
}
};
// ------------------------------------------------------------------------------------------------
BetterFilter bf;
for (auto &x : bf.filter(all, ColorSpecification(COLOR::GREEN)))
cout << x->m_name << " is green\n";
正如你所看到的,我们不需要修改BetterFilter
的过滤方法。它可以满足所有的specification
。
对于组合可以使用如下代码
template <typename T>
struct AndSpecification : Specification<T> {
const Specification<T> &first;
const Specification<T> &second;
AndSpecification(const Specification<T> &first, const Specification<T> &second)
: first(first), second(second) {}
bool is_satisfied(T *item) const {
return first.is_satisfied(item) && second.is_satisfied(item);
}
};
template <typename T>
AndSpecification<T> operator&&(const Specification<T> &first, const Specification<T> &second) {
return {first, second};
}
// -----------------------------------------------------------------------------------------------------
auto green_things = ColorSpecification{COLOR::GREEN};
auto large_things = SizeSpecification{SIZE::LARGE};
BetterFilter bf;
for (auto &x : bf.filter(all, green_things && large_things))
cout << x->m_name << " is green and large\n";
// warning: the following will compile but will NOT work
// auto spec2 = SizeSpecification{SIZE::LARGE} &&
// ColorSpecification{COLOR::BLUE}
对于两个以上的规范,可以使用可变参数模板。
更好的示例-spring示例
Spring的设计和实现非常完美,您可以扩展它的任何部分特性,并将您的自定义实现开箱即用。
好处
- 可扩展性
“当对程序的一次更改导致对相关模块的一连串更改时,该程序就会表现出我们认为与‘糟糕’设计相关联的不良属性。 程序变得脆弱、僵化、不可预测和不可重用。 开闭原则以非常直接的方式解决了这一问题。 它说你应该设计永不改变的模块。 当需求发生变化时,您可以通过添加新代码来扩展此类模块的行为,而不是通过更改已经工作的旧代码。”— 罗伯特·马丁 - 可维护性
这种方法的主要好处是接口引入了一个额外的抽象级别,可以实现松散耦合。 接口的实现是相互独立的,不需要共享任何代码。因此,您可以轻松应对客户不断变化的需求。 在敏捷方法中非常有用。 - 灵活性
- 开闭原则也适用于插件和中间件架构。 在这种情况下,您的基础软件实体就是您的应用程序核心功能。
- 在插件的情况下,您有一个基础或核心模块,可以通过通用网关接口插入新特性和功能。 Web 浏览器扩展就是一个很好的例子。
- 二进制兼容性也将在后续版本中保持不变。
里氏替换原则
定义
- Liskov 替换原则指出: “在计算机程序中,如果 S 是 T 的子类型,则类型 T 的对象可以被类型 S 的对象替换(即,类型 S 的对象可以替换类型 T 的对象)而不改变该程序的任何理想属性(正确性、执行的任务等)” 。
- 使用对基类的引用的方法必须能够在不知情的情况下使用派生类的对象。也就是说子类型必须可以替代它们的基本类型,而不会改变程序的正确性
- 如果我在 C++ 的上下文中解决这个问题,这实际上意味着使用指向基类的指针/引用的函数必须能够被其派生类替换。
- Liskov 替换原则围绕确保正确使用继承展开。
- LSP 有时被表示为
duck test
的反例:“如果它看起来像鸭子,叫起来像鸭子,但需要电池——你可能有错误的抽象”。马克思兄弟(Marx Brothers)对“鸭子测试”的重新表述是:“他可能看起来像个白痴,说话也像个白痴,但不要被这一点骗了。他真的是个白痴。”这句话的幽默之处在于它违背了预期的反面。通俗点将就是如果违背了LSP原则,则很有可能是我们使用了不正确的抽象。
如何判断是否违背LSP
LSP适用于存在超类型-子类型继承关系的情况,可以是类的扩展,也可以是接口的实现。我们可以将超类型中定义的方法视为定义契约。每个子类型都应该遵守这个契约。如果一个子类不遵守父类的契约,它就违反了LSP。
- 产生
子类中的方法如何打破父类方法的契约?有几种可能的方法:- 返回与超类方法返回的对象不兼容的对象。
- 抛出超类方法没有抛出的新异常。
- 更改语义或引入不属于父类契约的副作用。
- 一些识别指标
- 客户端代码中的条件逻辑(使用instanceof操作符或object.getClass().getName()来标识实际的子类)也就是说在多态代码块中存在类型检查代码多数是LSP违规了。
例如,如果你在Foo类型的对象集合上有一个std::for_each循环,在这个循环中,有一个检查Foo是否真的是Bar(Foo的一种子类型),那么几乎可以肯定这是一个LSP违规。相反,你应该确保Bar在所有方面都可以替代Foo,应该没有必要包括这样的检查。- - 子类中一个或多个方法是空的、不做任何事情的实现
- 从子类方法抛出UnsupportedOperationException或其他意外异常,从父类的契约角度来看,异常需要是不可预料的。因此,如果我们的超类方法的签名明确指定子类或实现可以抛出UnsupportedOperationException,那么我们就不会认为它违反了LSP。
- 客户端代码中的条件逻辑(使用instanceof操作符或object.getClass().getName()来标识实际的子类)也就是说在多态代码块中存在类型检查代码多数是LSP违规了。
示例
正方形和长方形的示例
- 违反了LSP
从数学的角度,正方形是长方形的特例,所以两者之间存在一种“是”的关系。这诱使我们创建一个继承自Rectangle类的Square类。
struct Rectangle {
Rectangle(const uint32_t width, const uint32_t height) : m_width{width}, m_height{height} {}
uint32_t get_width() const { return m_width; }
uint32_t get_height() const { return m_height; }
virtual void set_width(const uint32_t width) { this->m_width = width; }
virtual void set_height(const uint32_t height) { this->m_height = height; }
uint32_t area() const { return m_width * m_height; }
protected:
uint32_t m_width, m_height;
};
struct Square : Rectangle {
Square(uint32_t size) : Rectangle(size, size) {}
void set_width(const uint32_t width) override { this->m_width = m_height = width; }
void set_height(const uint32_t height) override { this->m_height = m_width = height; }
};
void process(Rectangle &r) {
uint32_t w = r.get_width();
r.set_height(10);
assert((w * 10) == r.area()); // Fails for Square <---
}
int main() {
Rectangle r{5, 5};
process(r);//(50==50)
Square s{5};
process(s);//void process(Rectangle&): Assertion `(w * 10) == r.area()' failed.(50!=25)
return EXIT_SUCCESS;
}
- 如你所见,我们在
void process(Rectangle &r)
函数中违反了liskov替换原则。因此Square
并不是Rectangle
的有效替代品。 - 如果从设计的角度来看,从
Rectangle
继承Square
并不是一个好主意。因为Square
没有高度和宽度,而是有边的大小/长度。 - 实际使用中,当处理一个复杂的控制层次结构时,需要在不同级别维护数十个字段,在运行时保持对象状态的一致性会变得非常棘手。
- 解决方法
》》》 不太好的方法
void process(Rectangle &r) {
uint32_t w = r.get_width();
r.set_height(10);
if (dynamic_cast<Square *>(&r) != nullptr)
assert((r.get_width() * r.get_width()) == r.area());
else
assert((w * 10) == r.area());
}
》》》 一个不错的方法
void process(Rectangle &r) {
uint32_t w = r.get_width();
r.set_height(10);
if (r.is_square())
assert((r.get_width() * r.get_width()) == r.area());
else
assert((w * 10) == r.area());
}
不需要为Square创建单独的类。相反,您可以简单地在Rectangle类中检查bool标志来验证Square属性。虽然不推荐这样做。
》》》 使用正确的继承层次结构
struct Shape {
virtual uint32_t area() const = 0;
};
struct Rectangle : Shape {
Rectangle(const uint32_t width, const uint32_t height) : m_width{width}, m_height{height} {}
uint32_t get_width() const { return m_width; }
uint32_t get_height() const { return m_height; }
virtual void set_width(const uint32_t width) { this->m_width = width; }
virtual void set_height(const uint32_t height) { this->m_height = height; }
uint32_t area() const override { return m_width * m_height; }
private:
uint32_t m_width, m_height;
};
struct Square : Shape {
Square(uint32_t size) : m_size(size) {}
void set_size(const uint32_t size) { this->m_size = size; }
uint32_t area() const override { return m_size * m_size; }
private:
uint32_t m_size;
};
void process(Shape &s) {
// Use polymorphic behaviour only i.e. area()
}
好处
- 兼容性
它支持多个版本和补丁之间的二进制兼容性。 换句话说,它使客户端代码免受影响。 - 类型安全
这是通过继承处理类型安全的最简单方法,因为在继承时类型不允许 变化 。 - 可维护性
- 遵循 LSP 的代码松散地相互依赖并鼓励代码可重用性。
- 遵循 LSP 的代码是进行正确抽象的代码。
依赖倒置原则
定义
1. 高级模块不应该依赖于低级模块。 两者都应该依赖于抽象。
2. 抽象不应该依赖于细节。 细节应该取决于抽象。
什么是高级和低级模块?
- 高级模块 : 描述本质上更抽象且包含更复杂逻辑的操作。 这些模块在我们的应用程序中编排低级模块。
- 低级模块 : 描述更具体和独立的组件的实现,专注于应用程序的细节和较小部分。 这些模块在高级模块内部使用。
实现
实现时注意以下几点:
- 每个类尽量提供接口或抽象类,或者两者都具备。
- 变量的声明类型尽量是接口或者是抽象类。
- 任何类都不应该从具体类派生。
- 使用继承时尽量遵循里氏替换原则。
示例
- 违反DIP原则
#include <iostream>
#include <utility>
#include <string>
#include <vector>
using namespace std;
enum class Relationship { parent, child, sibling };
struct Person {
string m_name;
};
struct Relationships { // 低级模块 <<<<<<<<<<<<-------------------------
vector<tuple<Person, Relationship, Person>> m_relations;
void add_parent_and_child(const Person &parent, const Person &child) {
m_relations.push_back({parent, Relationship::parent, child});
m_relations.push_back({child, Relationship::child, parent});
}
};
struct Research { // 高级模块 <<<<<<<<<<<<------------------------
Research(const Relationships &relationships) {
for (auto &&[first, rel, second] : relationships.m_relations) {// 需要c++17
if (first.m_name == "John" && rel == Relationship::parent)
cout << "John has a child called " << second.m_name << endl;
}
}
};
int main() {
Person parent{"John"};
Person child1{"Chris"};
Person child2{"Matt"};
Relationships relationships;
relationships.add_parent_and_child(parent, child1);
relationships.add_parent_and_child(parent, child2);
Research _(relationships);
return EXIT_SUCCESS;
}
/** 运行结果
John Chris
John has a child called Chris
Chris John
John Matt
John has a child called Matt
Matt John
**/
- 当
Relationships
的容器从vector
容器更改为set
容器或其他容器时,您需要在许多地方进行更改,这不是一个很好的设计。即使只是数据成员的名称(如Relationships::m_relations
)改变了,你也会发现自己破坏了代码的其他部分。 - 正如你所看到的,低级模块
Relationships
直接依赖于高级模块Research
,这本质上违反DIP原则。
- 解决方法
创建一个抽象,并将低级和高级模块绑定到那个抽象上。考虑以下修复方法:
struct RelationshipBrowser {
virtual vector<Person> find_all_children_of(const string &name) = 0;
};
struct Relationships : RelationshipBrowser { // 低级模块 <<<<<<<<<<<<<<<------------------------
vector<tuple<Person, Relationship, Person>> m_relations;
void add_parent_and_child(const Person &parent, const Person &child) {
m_relations.push_back({parent, Relationship::parent, child});
m_relations.push_back({child, Relationship::child, parent});
}
vector<Person> find_all_children_of(const string &name) {
vector<Person> result;
for (auto &&[first, rel, second] : m_relations) {
if (first.name == name && rel == Relationship::parent) {
result.push_back(second);
}
}
return result;
}
};
struct Research { // 高级模块 <<<<<<<<<<<<<<<----------------------
Research(RelationshipBrowser &browser) {
for (auto &child : browser.find_all_children_of("John")) {
cout << "John has a child called " << child.name << endl;
}
}
};
- 现在无论容器的名称或容器本身在低阶模块、高阶模块或DIP之后的其他部分代码中发生变化,都将保持不变。
- 依赖倒置原则(DIP)表明,最灵活的系统是那些源代码依赖关系只引用抽象而不引用具体的系统。
- 这就是为什么大多数有经验的开发者在使用通用容器的同时使用STL或库函数的原因。即使在适当的地方使用auto关键字也可以帮助创建具有更少脆弱代码的通用行为。
- 有许多方法可以实现DIP :CRTP 、模板特化、适配器设计模式、类型删除。
好处
- 可重用性
有效地,DIP 减少了不同代码段之间的耦合。 这样我们就得到了可重用的代码。 - 可维护性
- 同样重要的是要提到更改已经实现的模块是有风险的。 通过依赖抽象而不是具体实现,我们可以通过不必更改项目中的高级模块来降低风险。
- 最后,正确应用 DIP 可以在应用程序的整个架构级别为我们提供灵活性和稳定性。 我们的应用程序将能够更安全地发展并变得稳定和健壮。
接口隔离原则
定义
2002 年罗伯特·C.马丁给“接口隔离原则”的定义是:客户端不应该被迫依赖于它不使用的方法(Clients should not be forced to depend on methods they do not use)。该原则还有另外一个定义:一个类对另一个类的依赖应该建立在最小的接口上(The dependency of one class to another one should depend on the smallest possible interface)。
以上两个定义的含义是:要为各个类建立它们需要的专用接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。
接口隔离原则和单一职责都是为了提高类的内聚性、降低它们之间的耦合性,体现了封装的思想,但两者是不同的:
- 单一职责原则注重的是职责,而接口隔离原则注重的是对接口依赖的隔离。
- 单一职责原则主要是约束类,它针对的是程序中的实现和细节;接口隔离原则主要约束接口,主要针对抽象和程序整体框架的构建。
实现
应该根据以下几个规则来衡量。
- 接口尽量小,但是要有限度。 一个接口只服务于一个子模块或业务逻辑。
- 为依赖接口的类定制服务。只提供调用者需要的方法,屏蔽不需要的方法。
- 了解环境,拒绝盲从。每个项目或产品都有选定的环境因素,环境不同,接口拆分的标准就不同深入了解业务逻辑。
- 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
示例
- 错误的示例
struct Document;
struct IMachine {
virtual void print(Document &doc) = 0;
virtual void fax(Document &doc) = 0;
virtual void scan(Document &doc) = 0;
};
struct MultiFunctionPrinter : IMachine { // OK
void print(Document &doc) override { }
void fax(Document &doc) override { }
void scan(Document &doc) override { }
};
struct Scanner : IMachine { // Not OK
void print(Document &doc) override { /* Blank */ }
void fax(Document &doc) override { /* Blank */ }
void scan(Document &doc) override {
// Do scanning ...
}
};
- 就
MultiFunctionPrinter
而言,它可以实现由IMachine
接口强制执行的print()
、fax()
和scan()
方法。 - But what if you only need a Scanner or Printer, some dev still inherits IMachine & leave unnecessary methods blank or throw NotImplemented exception, either way, you are doing it wrong.
- 但如果你只需要一个
Scanner
或Printer
,一些开发仍然继承IMachine
并且留下了一些空的方法或抛出NotImplemented异常,无论哪种方式,都是错的。
- 接口隔离的示例
/* -------------------------------- Interfaces ----------------------------- */
struct IPrinter {
virtual void print(Document &doc) = 0;
};
struct IScanner {
virtual void scan(Document &doc) = 0;
};
/* ------------------------------------------------------------------------ */
struct Printer : IPrinter {
void print(Document &doc) override;
};
struct Scanner : IScanner {
void scan(Document &doc) override;
};
struct IMachine : IPrinter, IScanner { };
struct Machine : IMachine {
IPrinter& m_printer;
IScanner& m_scanner;
Machine(IPrinter &p, IScanner &s) : printer{p}, scanner{s} { }
void print(Document &doc) override { printer.print(doc); }
void scan(Document &doc) override { scanner.scan(doc); }
};
- 这为客户端提供了灵活性,可以根据他们认为合适的情况组合抽象,并提供不需要的实现。
- 如单一责任原则所述。你应该避免承担多重责任的类和接口。因为它们经常变化,使您的软件难以维护。您应该尝试根据角色将接口拆分为多个接口。
好处
- 更快的编译
如果你违反了ISP,即在接口中填充方法,当方法签名改变时,你需要重新编译所有的派生类 - 可重用性
Martin还提到了“臃肿接口”——带有额外无用方法的接口——会导致类之间的无意耦合。因此,有经验的开发人员知道耦合是可重用性的祸根。 - 可维护性
更普遍的ISP好处是,通过避免不必要的依赖,系统变得- 更容易理解;
- 轻测试;
- 更快地改变。
迪米特法则
定义
- 火车残骸问题
让我们做一些想象吧! 思考……思考……,想象一列火车在行驶中,由一系列连接在一起的车辆组成,如果中间的车辆发生故障,火车最终将无法按预期到达目的地。 与此类似,在编程中,当您将模块中的不同对象交织在一起以实现某种功能时,如果连接中的至少一个对象发生故障,您就更接近于对整个模块造成严重破坏。
换言之,如果方法/对象/模块中的错误或更改与其他链接在一起,就会导致事故发生 - 定义
迪米特法则又称为最少知道原则,它表示一个对象应该对其它对象保持最少的了解。通俗来说就是,只与直接的朋友通信。
一个模块不应该知道它所操纵的对象的内部细节。 换句话说,软件组件或对象不应该了解其他对象或组件的内部工作。
示例.
一个对象a可以请求一个对象实例b的服务(调用一个方法),但是对象a不应该“通过”对象b访问另一个对象c来请求它的服务。这样做意味着对象a隐含地需要对对象b的内部结构有更深入的了解
相反,如果需要,应该修改b的接口,以便它可以直接服务于对象a的请求,并将其传播到任何相关的子组件。或者,a可以直接引用对象c,并直接向该对象发出请求。如果遵循这个规律,只有物体b知道自己的内部结构。
更正式地说,函数的得迪米特原则要求对象a的方法m只能调用以下类型对象的方法:
- a 自己;
- m参数;
- 在m内实例化的任何对象
- a的属性
- a在m范围内可访问的全局变量。
总结而言,对象的任何方法都应该只调用属于以下的方法:
- 本身
- 传入方法的任何参数
- 它创建的任何对象
- 任何直接持有的组件对象
特别地,一个对象应该避免调用另一个方法返回的对象的方法。对于许多使用点作为字段标识符的现代面向对象语言来说,该法则可以简单地表述为“只使用一个点”。也就是说,代码a.m. ().n()违反了a.m.()没有遵守的法律。打个比方,想让狗走路,不能直接命令它的腿走路;相反,一个人命令狗,然后狗命令自己的腿。
a.getB().methodB(); // 违背
a.methodB(); // 没有违背
好处
- 减少类之间的依赖和耦合
- 可重用性
- 更易测试
- 可维护性
组合/聚合复用原则
定义
- 组合、聚合VS继承
合成和聚合都是关联的特殊种类。合成是值的聚合(Aggregation by Value),而复合是引用的聚合(Aggregation by Reference)。
组合是一种较为紧密的关系,从生命周期上看,部分和整体是共存亡的关系。
聚合则是一种较为松散的关系,部分和整体的生命周期未必一致。
组合/聚合VS继承
- 封裝性
继承父类别时,夫类别的实现细节会暴漏给子类别,所以继承会破坏封装性。而由于父类对于子类透明,所以继承也称作白箱复用
合成/聚合时使用的类别并不知道原始类的实现细节,所以维持了类的封装性,又叫黑箱复用。- 耦合度
继承时父类与子类耦合度高,父类任何更改都会影响子类,降低了子类的扩充及维度。
合成/聚合時新类的耦合度及依赖程度低,新类只能通过复用类的接口取得內容。- 灵活性
继承限制了整体的灵活性。由于从父类继承的方法是静态,不可能在执行时发生变化。
合成/聚合的灵活性较高。合成/聚合执行时,新类可以自由的引用组合类别的相同对象。
- 定义
组合/聚合复用原则(Composite/Aggregate Reuse Principle)是面向对象设计原则的一种,也叫合成复用原则。组合/聚合复用原则是指尽量使用组合/聚合,不要使用类继承。在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新对象通过向这些对象的委派达到复用已有功能的目的。就是说要尽量的使用合成和聚合,而不是继承关系达到复用的目的。
示例
好处
- 它维持了类的封装性。因为成分对象的内部细节是新对象看不见的,所以这种复用又称为“黑箱”复用。
- 新旧类之间的耦合度低。这种复用所需的依赖较少,新对象存取成分对象的唯一方法是通过成分对象的接口。
- 复用的灵活性高。这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象。
缺点
需要维护较多的类。
参考
[1] A simple example of the Open/Closed Principle
[2] Open closed principle
[3] Single Responsibility Principle in C++ | SOLID as a Rock
[4] Duck test
[5] The Liskov Substitution Principle Explained