突破编程_C++_高级教程(面向对象编程的原则)

1 面向对象编程的原则基础概念

面向对象编程(Object-Oriented Programming,简称OOP)主要有五个,这些原则帮助开发者创建出更加健壮、可维护、可扩展的软件系统。以下是这五个原则及其概述:
单一职责原则( Single Responsibility Principle ,简称 SRP )
这个原则表明一个类应该只有一个引起其变化的原因。也就是说,一个类应该只有一个职责,只有一个改变它的原因。如果一个类承担的职责过多,就等于把这些职责耦合在一起,一个职责的变化可能会削弱或者抑制这个类完成其他职责的能力,这种耦合会导致脆弱的设计,当变化发生时,设计会遭受到意想不到的破坏。
开放-封闭原则( Open-Closed Principle ,简称 OCP )
这个原则是说软件实体(类、模板、函数等)应该可以扩展,但是不可修改。开放-封闭原则有两个特征:一是对扩展是开放的,二是对更改是封闭的。实现开放-封闭原则的核心思想是对抽象编程,而不是对具体编程,因为抽象相对稳定。开放-封闭原则是面向对象设计的核心,遵循这个原则可以带来许多好处,例如,可维护、可扩展、可复用、灵活性好等。
依赖倒转原则( Dependence Inversion Principle ,简称 DIP )
这个原则要求高层模块不应该依赖低层模块,两者都应该依赖抽象(抽象类或接口)。换句话说,就是要针对接口编程,不要对实现编程。
接口隔离原则( Interface Segregation Principle ,简称 ISP )
这个原则的意思是使用多个隔离的接口,比使用单个接口要好。使用多个特定的接口,而不使用单一的总接口,客户端将只会被它们感兴趣的方法所影响。
里氏代换原则( Liskov Substitution Principle ,简称 LSP )
里氏代换原则是对开闭原则的补充。它要求子类必须能够替换它们的父类,而程序没有变化。换句话说,在软件工程中,子类型必须能够以其基类型的方式使用。

基于面向对象编程的原则做代码设计可以提高代码质量和可维护性,使代码更加清晰、简洁和易于理解。这有助于降低代码的复杂性,提高可读性,从而使代码更容易维护和修改。另外, OOP 原则强调通过添加新代码来实现新功能,而不是修改现有代码。这有助于保持系统的稳定性,并使其更加容易扩展。同时,多态性的支持也使得系统更加灵活,可以通过在运行时动态选择实现类来适应不同的需求。
不过在使用该原则的过程中也要注意以下几点:
避免过度设计
虽然 OOP 原则有助于提高代码质量,但过度应用这些原则可能会导致过度设计。过度设计可能会使代码变得过于复杂和难以理解,因此需要在设计和实现之间找到平衡。
注意类的职责和大小
遵循单一职责原则时,需要确保每个类只有一个主要职责。然而,如果过度细分类的职责,可能会导致类的数量过多,反而增加系统的复杂性。因此,需要合理控制类的大小和职责范围。
接口的设计和使用
接口是 OOP 中的重要概念,但过度使用接口可能导致代码变得过于抽象和难以理解。因此,在设计接口时需要谨慎考虑,确保接口的数量和复杂度适中。
注意继承和多态的使用
继承和多态是 OOP 的重要特性,但如果不当使用,可能会导致代码变得难以维护和扩展。例如,过度使用继承可能导致类层次结构过于复杂,而过度使用多态可能导致运行时性能下降。因此,在使用继承和多态时需要谨慎考虑,并遵循相关原则。

2 单一职责原则

单一职责原则的核心思想是:一个类应该只有一个引起其变化的原因。换句话说,一个类应该只有一个主要的职责或功能。如果一个类承担的职责过多,那么当其中一个职责发生变化时,可能会影响到其他职责,导致系统变得脆弱和难以维护。
在 C++ 中实现单一职责原则的关键是合理划分类的职责,确保每个类只负责一个特定的功能或任务。这有助于降低类的复杂性,提高代码的可读性和可维护性。
如下是一个违反单一职责原则的例子:

// 违反单一职责原则的示例  
class UserManager 
{  
public:  
	void readUserDb();		// 从数据库获取用户信息
    void addUser(const std::string& username);  
    void removeUser(const std::string& username);  
    bool authenticateUser(const std::string& username, const std::string& password);   
    void calculatePoints();  	// 从计算用户积分
    void sendNotification(const std::string& username, const std::string& message);  
    // ... 其他与用户管理相关的功能  
};  

在上面代码中,UserManager 类违反了单一职责原则,因为它负责了用户管理(添加、删除、认证用户)和发送通知两个不同的职责。这可能导致代码难以理解和维护,因为任何与用户管理或通知相关的更改都可能影响到 UserManager 类的实现。针对上面的设计,有如下 4 各方面的影响:
耦合度提高
当类承担多个职责时,它们之间的耦合度通常会提高。如上面代码中 UserManager 需要访问数据库来获取用户信息,而当前数据库访问逻辑与用户管理逻辑紧密耦合在一起。这导致如果数据库访问层发生变化, UserManager 类也可能需要相应地进行修改。
难以测试和维护
复杂的类通常更难进行单元测试,因为需要模拟更多的依赖关系和行为。如果一个类负责多个功能,那么测试其中一个功能时可能需要设置和验证其他不相关的功能,这增加了测试的复杂性。同样,当出现问题时,定位和解决问题也会更加困难。假设需要修改上面代码中 UserManager 类中的计算用户积分逻辑,但由于该类同时还负责发送修改后的通知,那就要需要确保在修改用户积分逻辑时不会影响到这个功能。如果之前的代码没有良好的单元测试覆盖,这可能会引入新的错误或问题。
灵活性降低
如果一个类承担了多个职责,那么当需要修改或扩展其中一个职责时,可能需要对整个类进行修改。这降低了系统的灵活性,因为对一个小功能的修改可能会影响到整个类的其他部分。
代码可读性下降
违反单一职责原则可能导致类的代码变得冗长且难以阅读。每个函数可能都执行多个不相关的操作,这使得理解每个函数的目的和如何使用它变得更加困难。尤其是对于类名的理解,上面代码中的 UserManager 看起来好像仅是对用户信息的增删改查负责的一个类,但是又负责了计算积分以及发送通知等功能,很难让人理解。
为了避免这些后果,应该遵循单一职责原则,将不同的职责划分到不同的类中。例如,可以有 DbReader 类负责读取数据库信息, UserManager 类负责用户的增删改查,PointsCalculator 类负责积分计算,NotificationService 类负责发送通知。这样,每个类都只负责一类特定的功能,提高了代码的可读性、可维护性和可扩展性。如下为修改后的代码:

// 遵循单一职责原则的示例  
class DbReader 
{  
public:  
	void readUserDb();		// 从数据库获取用户信息
};  
  
class UserManager 
{  
public:  
    void addUser(const std::string& username);  
    void removeUser(const std::string& username);  
    bool authenticateUser(const std::string& username, const std::string& password);  
};

class PointsCalculator 
{  
public:  
    void calculatePoints();  	// 从计算用户积分
};

class NotificationService 
{  
public:  
    void sendNotification(const std::string& username, const std::string& message);  
};

3 开放-封闭原则

开放-封闭原则的核心思想是保持软件实体的稳定性,同时允许其通过扩展来适应变化。换句话说,它鼓励开发人员设计可扩展的软件结构,这样当需求发生变化时,可以通过添加新功能或组件来满足这些变化,而不需要修改已有的代码。
遵循开放-封闭原则可以带来许多优点,包括:
(1)提高软件的可维护性:由于软件实体对修改封闭,已有的代码不需要频繁修改,从而降低了引入新错误的风险。
(2)提高软件的可扩展性:通过添加新代码来实现新功能,可以轻松地扩展软件实体的行为,以适应不断变化的需求。
(3)提高软件的可复用性:抽象基类和接口的设计使得代码更加模块化,提高了代码的可复用性。
(4)提高软件设计的灵活性:通过面向抽象编程,可以更加灵活地应对需求的变化,而不需要对整个系统进行重构。
开放-封闭原则的实现方式
要实现开放-封闭原则,关键在于使用抽象和多态。通过定义抽象基类或接口,可以创建一个稳定的软件架构,其中具体的实现类可以根据需要进行扩展,而不需要修改已有的代码。
在 C++ 中,可以通过虚函数和纯虚函数来实现抽象基类。纯虚函数是一个在基类中声明但没有实现的函数,它要求所有派生类都必须提供该函数的实现。通过这种方式,基类定义了一个接口,而派生类则提供了具体的实现。当需要扩展软件实体的行为时,只需要创建新的派生类,并实现所需的虚函数即可。
假设有一个简单的计算器程序,它支持基本的加减乘除运算。为了遵循开放-封闭原则,可以设计一个抽象的 Operation 类,其中包含一个纯虚函数 calc() ,用于执行具体的运算操作。然后,可以为每种运算(如加法、减法、乘法、除法)创建一个派生类,并实现 calc() 函数。当需要添加新的运算功能时,只需要创建新的派生类,并实现 calc() 函数即可,而不需要修改已有的代码。如下为样例代码:

class Operation 
{
public:
	Operation(double a, double b) : m_a(a), m_b(b) {}
	virtual double calc() const = 0; // 纯虚函数 

protected:
	double m_a;
	double m_b;
};

class Addition : public Operation
{
public:
	double calc() const override { return m_a + m_b; }
};

class Subtraction : public Operation 
{
public:
	double calc() const override { return m_a - m_b; }
};

在这个示例中,Operation 类定义了一个抽象的接口,而具体的运算操作则由派生类来实现。当需要添加新的运算功能时,只需要创建新的派生类,并实现 calc() 函数即可。这样,就可以在不修改已有代码的情况下,通过扩展来适应新的需求。
但是,如果违反了开放-封闭原则,则可能会直接在 Operation 类中添加各种计算的代码,或者修改已有的 Addition 或 Subtraction 类来包含其他计算的逻辑:

class Operation 
{
public:
	enum OperationType
	{
		ADD,
		SUB,
	};
public:
	Operation(double a, double b) : m_a(a), m_b(b) {}
	double calc(int type) const
	{
		switch (type)
		{
		case OperationType::ADD:
		{
			return m_a + m_b;
		}
		case OperationType::SUB:
		{
			return m_a - m_b;
		}
		default:
			break;
		}
	}

protected:
	double m_a;
	double m_b;
};

上面违反开放-封闭原则可能会带来如下问题:
代码修改风险
直接修改 Operation 类或已有的派生类可能会引入新的错误,影响已有功能的正确性。
代码耦合度提高
如果在 Operation 类中添加各种计算的代码,那么 Operation 类将变得与具体的计算实现紧密耦合,从而使 Operation 类变得更加复杂,难以理解和维护。这违反了单一职责原则。
扩展性受限
计算逻辑被限定在 Operation 类的 calc 函数中,对于复杂的计算很难扩展(比如这个计算本身还需要依赖其他功能)。
测试难度增加
修改已有的代码可能会影响到已有的测试用例,导致需要重写或增加新的测试。
设计灵活性降低
违反 OCP 的设计将变得僵硬,难以适应未来的变化。

正确的做法应该是遵循开放-封闭原则,通过添加新的派生类来实现新的形状,而不是修改已有的代码。这样,可以保持已有代码的稳定性,同时通过扩展来适应新的需求。

4 依赖倒转原则

依赖倒转原则强调高层模块不应该依赖于低层模块,而是应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。这个原则的核心思想是减少模块间的耦合度,提高系统的灵活性和可扩展性。该原则要求在程序设计中,高层模块(如用户界面、业务逻辑层等)不应该直接依赖于低层模块(如数据访问层、底层库等)。相反,高层模块应该依赖于抽象接口或基类,而低层模块则实现这些接口或基类。这样一来,高层模块与低层模块之间的依赖关系就通过抽象层进行了解耦。
在 C++ 中,依赖倒转原则通常通过抽象类(包含纯虚函数的类)和接口(纯抽象类)来实现。高层模块持有对抽象类或接口的引用或指针,而不是对具体实现类的引用或指针。低层模块则提供实现这些抽象类或接口的具体类。
依赖倒转原则的优点包括:
降低模块间的耦合度
高层模块与低层模块之间的依赖关系通过抽象层进行解耦,使得模块间的耦合度降低,提高了系统的可维护性和可扩展性。
提高系统的灵活性
由于高层模块依赖于抽象接口或基类,我们可以轻松地替换低层模块的具体实现,而不需要修改高层模块的代码。
遵循开闭原则
依赖倒转原则是开闭原则( OCP )的主要实现机制之一。通过面向接口编程,可以遵循开闭原则,即对扩展开放,对修改封闭。
促进代码重用
通过定义通用的抽象接口或基类,可以促进代码的重用,减少重复的代码实现。

如下是一个简单的代码样例,演示了依赖倒转原则的应用:

#include <iostream>  

// 抽象接口  
class IDataAccess 
{
public:
	virtual void fetchData() = 0;
	virtual void saveData() = 0;
};

// 低层模块的具体实现  
class DatabaseAccess : public IDataAccess 
{
public:
	void fetchData() override 
	{
		// 实现从数据库获取数据的逻辑  
	}

	void saveData() override 
	{
		// 实现向数据库保存数据的逻辑  
	}
};

// 高层模块,依赖于抽象接口  
class UserService 
{
public:
	UserService(IDataAccess* dataAccess) : m_dataAccess(dataAccess) {}

	void performOperations() 
	{
		m_dataAccess->fetchData();
		// ... 执行其他业务逻辑 ...  
		m_dataAccess->saveData();
	}

private:
	IDataAccess* m_dataAccess;
};

int main() 
{
	IDataAccess* databaseAccess = new DatabaseAccess;
	UserService userService(databaseAccess);
	userService.performOperations();

	return 0;
}

在上面代码中,UserService 是一个高层模块,它依赖于抽象接口 IDataAccess ,而不是具体的 DatabaseAccess 类。 DatabaseAccess 是低层模块,它实现了 IDataAccess 接口。通过依赖倒转原则的应用, UserService 与 DatabaseAccess 之间的依赖关系被解耦,使得可以在不修改 UserService 代码的情况下,替换掉 DatabaseAccess 的实现,比如换成文件访问或内存访问等。
如果违反依赖倒转原则, UserService 直接依赖 DatabaseAccess :

// 高层模块,依赖于抽象接口  
class UserService 
{
public:
	UserService(DatabaseAccess* dataAccess) : m_dataAccess(dataAccess) {}

//其他代码
private:
	DatabaseAccess* m_dataAccess;
	
}

这种实现方式,会造成一下后果:
紧耦合
UserService 与 DatabaseAccess 之间存在紧耦合关系。如果 DatabaseAccess 的实现发生变化,UserService 也可能需要相应地进行修改。
扩展性受限
如果想要替换 DatabaseAccess 的实现(例如,用文件存储代替数据库),需要修改 UserService 的代码来适应新的实现。
测试难度增加
由于 UserService 直接依赖于 DatabaseAccess 的具体实现,测试 UserService 时可能需要创建和配置 DatabaseAccess 的实例,这增加了测试的复杂性。
违反了开闭原则
由于 UserService 直接依赖于 DatabaseAccess 的具体实现,当需要修改或扩展 DatabaseAccess 时,可能不得不修改 UserService ,这违反了开闭原则。
代码复用性降低
如果其他系统也需要类似的图书管理逻辑,但由于 UserService 直接依赖于具体的 DatabaseAccess,这些系统可能无法重用 UserService 的代码。

5 接口隔离原则

接口隔离原则主要指导如何设计接口,使其更加合理、灵活和可维护。该原则由 Robert C. Martin 提出,其核心思想是将臃肿庞大的接口拆分成更小、更专一的接口,客户端(使用接口的类)不应该被迫依赖于它们不需要的接口。换句话说,应该为每个类建立专用的接口,而不是试图建立一个庞大的接口供所有依赖它的类去调用。
接口隔离原则遵循高内聚、低耦合的设计思想。通过将接口拆分为多个小接口,每个接口只包含特定功能的方法,客户端只需要依赖于它所需要的接口,而不是不相关的方法。这样可以减少类之间的耦合度,提高代码的可读性、可维护性和可扩展性。
接口隔离原则的优点
降低耦合度
通过将接口拆分为多个小接口,降低了类之间的耦合度,使得系统更加灵活和可维护。
提高代码质量
接口更加明确和专一,有助于提高代码的可读性和可维护性。
易于扩展
当需要添加新功能时,只需要在相应的接口中添加新方法,而不需要修改现有的接口。

假设有一个 Animal 接口,其中包含了 eat 、 sleep 和 fly 三个方法。如果遵循接口隔离原则,可以将这个接口拆分为三个更小的接口: Eatable 、 Sleepable 和 Flyable 。如下为样例代码:

// Eatable接口  
class Eatable 
{
public:
	virtual void eat() = 0;
};

// Sleepable接口  
class Sleepable 
{
public:
	virtual void sleep() = 0;
};

// Flyable接口  
class Flyable 
{
public:
	virtual void fly() = 0;
};

// Dog类实现Eatable和Sleepable接口  
class Dog : public Eatable, public Sleepable 
{
public:
	void eat() override {
		// 实现吃的方法  
	}

	void sleep() override {
		// 实现睡觉的方法  
	}
};

// Bird类实现Eatable、Sleepable和Flyable接口  
class Bird : public Eatable, public Sleepable, public Flyable
{
public:
	void eat() override {
		// 实现吃的方法  
	}

	void sleep() override {
		// 实现睡觉的方法  
	}

	void fly() override {
		// 实现飞的方法  
	}
};

上面的代码遵循了接口隔离原则,将 Animal 接口拆分为三个更小的接口。这样, Dog 类只需要实现 Eatable 和 Sleepable 接口,而 Bird 类则需要实现所有三个接口。这种设计方式使得代码更加清晰、灵活和可维护。
如果不遵循接口隔离原则,则可以用如下方式实现:

class Animal 
{
public:
	virtual void eat() = 0;
	virtual void sleep() = 0;
	virtual void fly() = 0;
};

// Dog 类实现 Animal
class Dog : public Animal
{
public:
	void eat() override {
		// 实现吃的方法  
	}

	void sleep() override {
		// 实现睡觉的方法  
	}

	void fly() override {}	// 将飞行的方法逻辑置空
};

这种实现方式,会造成一下后果:
功能冗余
Dog 类实例化了 Animal 对象,这浪费了系统资源,因为 Dog 并不需要飞行的方法。
测试和维护困难
由于 Animal 类依赖于一个庞大的接口,编写和维护单元测试可能会更加困难。此外,任何对 Animal 接口的更改都可能影响到 Dog 类,增加了维护成本。
代码可读性下降
Dog 类使用了一个包含许多不相关方法的接口,这可能会降低代码的可读性和可理解性。

6 里氏代换原则

里氏代换原则是对继承复用的一种约束规范。这个原则主要阐述了有关继承的一些原则,即什么时候应该使用继承,什么时候不应该使用继承,以及其中蕴含的原理。
里氏代换原则的核心思想是:子类必须能够替换其父类。这意味着在软件中,任何使用基类(父类)的地方,都可以使用其子类而不需要修改原有的代码。这确保了基类与子类之间的兼容性,使得子类可以在不破坏原有功能的前提下扩展基类的功能。
要满足里氏代换原则,子类在继承父类时需要注意以下几点:
(1)子类可以扩展父类的功能但不能改变父类原有的功能。这意味着子类在添加新功能时,不应该覆盖或重写父类的方法,除非这些方法在基类中被声明为纯虚函数。
(2)子类可以实现父类的抽象方法,但不应该覆盖父类的非抽象方法。这样可以确保子类在实现父类功能的同时,不会破坏父类的行为约定。
(3)子类中可以增加自己特有的方法。这样可以实现子类的独特功能,同时保持与父类的兼容性。
(4)当子类的方法重载父类的方法时,方法的前置条件(即方法的输入参数)要比父类的方法更宽松。这可以确保子类在替换父类时,不会因为参数不匹配而导致程序出错。
(5)当子类的方法实现父类的方法时(重写、重载或实现抽象方法),方法的后置条件(即方法的输出与返回值)要比父类的方法更严格或相等。这可以确保子类在替换父类时,不会因为返回值不匹配而导致程序出错。
里氏代换原则是对继承复用的一种约束,它要求子类在继承父类时,必须保持与父类的兼容性。这有助于降低代码的耦合度,提高代码的可维护性和可扩展性。同时,它也指导我们在设计类时,要遵循良好的继承关系,避免因为不恰当的继承导致的问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值