一、让类尽可能小
对单个类我们建议其行数不超过50行,但这是一种不严谨的标准,我们将采用职责的项数来度量一个类的大小。下面将提到一些原则。
二、单一职责原则(SRP)
单一职责原则(Single Responsibility Principle)规定,每个软件单元,其中包括组件、类和航母是,应该只有一个单一且明确定义的职责。
三、开闭原则(OCP)
开闭原则(Open Closed Principle)指出软件实体(模块、类、函数等)对扩展应该是开放的,但是对修改应该是封闭的。将需要扩展的代码抽象出来,通过扩展来增加新的功能,而不修改原有的代码。开闭原则具体的实践有两种可能,其中一种是将变化依赖成接口;另一种,将变化抽象成方法,具体交给子类。
四、里氏替换原则(LSP)
里氏替换原则(Liskov Substitution Principle)的简单表述:子类不能移除父类已经实现的功能,子类处理的问题应该比父类的广,如果遇到需要移除父类部分功能的时候,尝试使用组合的方式将父类作为子类的成员来使用。
4.1 实例
考虑下面一种情况:我们有一个矩形类Rect,现在我们想要实现正方形类Squ。
class Rect
{
private:
int x, y;
public:
// 构造函数
Rect(const int& p_x = 3, const int& p_y = 5)
{
x = p_x;
y = p_y;
}
// 设置高
void setHeight(int p_y)
{
y = p_y;
}
// 设置宽
void setWidth(int p_x)
{
x = p_x;
}
// 取得面积
int GetArea()
{
return x * y;
}
};
4.2 不好的实现
如果我们要使用继承的方式来实现Squ,将是如下方式(但继承是不好的方式)。这里我们需要使用virtual将setWidth()和setHeight()声明为虚函数,之后在Squ中覆写基函数:
class Squ :public Rect
{
public:
Squ(const int& p_e = 3) :Rect{ p_e, p_e } {};
virtual void setHeight(int p_e) override
{
Rect::setHeight(p_e);
Rect::setWidth(p_e);
}
virtual void setWidth(int p_e) override
{
Rect::setHeight(p_e);
Rect::setWidth(p_e);
}
void setEdge(int p_e)
{
Rect::setHeight(p_e);
Rect::setWidth(p_e);
}
};
这里使用虚函数是不好的,它试图“移除”基类的方法。而使用常规的继承(不适用virtual),则基类的方法也被继承到子类中,虽然这些方法不会被使用。
4.3 好的实现
class Squ
{
private:
Rect impl;
public:
Squ(const int& p_e = 3)
{
impl = Rect(p_e, p_e);
}
void setEdge(int p_e)
{
impl.setHeight(p_e);
impl.setWidth(p_e);
}
int GetArea()
{
return impl.GetArea();
}
};
这里将父类作为子类的成员来使用,避免了继承带来的一系列问题。这便是里氏替换原则的简单实例。
五、接口隔离原则(ISP)
接口隔离原则(Interface Segregation Principe)指出接口不应该包含那些与实现类无关的成员函数,或者这些类不能以有意义的方式实现。我们应该将宽接口分解成更小且高内聚的接口。生成的小接口也称为角色接口。
5.1 实例
考虑下面一个例子,我们有一个Bird类、我们想实现Sparrow类和Penguin类。
class Bird
{
public:
virtual void eat();
virtual void run();
virtual void fly();
};
class Sparrow :public Bird
{
public:
virtual void eat() override;
virtual void run() override;
virtual void fly() override;
};
class Punguin :public Bird
{
public:
virtual void eat() override;
virtual void run() override;
virtual void fly() override;
// ?
};
对于上述情况,企鹅是不会飞的,为了应对这种情况,我们需要拆分他们的接口:
5.2 拆分接口后
class Lifeform
{
public:
virtual void eat() = 0;
virtual void run() = 0;
};
class Flyable
{
public:
virtual void fly() = 0;
};
class Sparrow : public Lifeform, public Flyable
{
// do sth
};
class Penguin : public Lifeform
{
// do sth
};
六、无循环依赖原则
考虑下面一个情况,我们需要实现一个购物系统,有Customer和Account两个类:
6.1 实例
// Customer.h
class Customer
{
private:
Account customerAccount;
};
// Account.h
class Account
{
private:
Customer owner;
};
这样是无法通过编译的,但是我们可以使用前置声明的方式使它通过编译。这里前置的是标识符的声明,但是不定义该标识符的完整结构,这些类型有时候又被称为不完整类型。因此,只能声明它们的指针或引用,而不能用于实例化的成员变量,因为编译器对其大小一无所知。
6.2 使用前置声明
// Account.h
#pragma once
class Customer;
class Account
{
private:
Customer* owner;
public:
Account() { /* do nothing */ }
void setOwner(Customer* p_owner)
{
owner = p_owner;
}
};
// Customer.h
#pragma once
class Account;
class Customer
{
private:
Account* customerAccount;
public:
Customer() { /* do nothing */ }
void setAccount(Account* p_Account)
{
customerAccount = p_Account;
}
};
// main.cpp
#include <iostream>
#include "Account.h"
#include "Customer.h"
int main()
{
Account* acc = new Account{ };
Customer* cus = new Customer{ };
acc->setOwner(cus);
cus->setAccount(acc);
return 0;
}
这样虽然解决了编译的问题,但是导入了循环依赖,当Account实例被删除的时候,Customer中的Account仍然存在,并且指向被删除的Account。这将导致不确定状态。下面我们将介绍打破循环依赖的方法。
七、依赖转置原则(DIP)
7.1 Cus-Acc案例的依赖转置
下面我们可以使用一个接口类来打破类上的循环依赖:
通过上述的操作,我们阻断了类与类之间的循环依赖,但是仍然存在组件与组件之间的依赖,下面我们对它修改:
我们稍作修改已经消除了部分依赖,但是却引入的新的依赖。依赖倒置原则(Dependency Inversion Principe):
- 高级模块不应该依赖于低级模块,两种都应该依赖于抽象类。
- 抽象依赖不应该依赖于细节,细节应该依赖于抽象。即:高层 → 抽象类、接口 ← 底层
下面我们重新设计: