写代码设计原则是指在编写代码时,遵循一些通用的指导原则,以确保代码的可读性、可维护性、可扩展性和可重用性。这些原则有助于开发人员创建出高质量的软件系统。下面我将介绍几个常见的代码设计原则,并通过C++代码例子来说明它们的应用。
1. 单一职责原则(Single Responsibility Principle, SRP)
含义:一个类、函数或模块只应负责一项职责,即只有一个引起它变化的原因。
换句话说,一个类应该只有一个职责。如果一个类有多个职责,那么这些职责就可能会相互干扰,导致类的设计变得脆弱和难以维护。
例子1:假设我们要编写一个程序来管理学生的信息,包括成绩和个人信息。如果不遵循单一职责原则,我们可能会创建一个包含所有功能的庞大类。但是,按照单一职责原则,我们应该将不同的职责分离到不同的类中。
cpp
// 违反单一职责原则
class StudentManager {
public:
void AddStudent(Student s) { /*...*/ }
void UpdateGrade(Student s, Grade g) { /*...*/ }
void PrintStudentInfo(Student s) { /*...*/ }
// ... 其他与学生管理相关的功能
};
// 遵循单一职责原则
class StudentRepository {
public:
void AddStudent(Student s) { /*...*/ }
// ... 其他与学生存储相关的功能
};
class GradeManager {
public:
void UpdateGrade(Student s, Grade g) { /*...*/ }
// ... 其他与成绩管理相关的功能
};
class StudentPrinter {
public:
void PrintStudentInfo(Student s) { /*...*/ }
// ... 其他与学生信息打印相关的功能
};
在这个例子中,我们将学生管理功能拆分为三个类:StudentRepository
负责学生信息的存储,GradeManager
负责成绩管理,StudentPrinter
负责打印学生信息。这样每个类都只负责一个特定的职责。
例子2:假设我们有一个类既负责处理用户认证又负责处理用户授权。
违反单一职责原则的代码可能是这样的:
cpp
class AuthManager {
public:
void AuthenticateUser(const std::string& username, const std::string& password) {
// 实现用户认证逻辑
}
void AuthorizeUser(const std::string& username, const std::string& resource) {
// 实现用户授权逻辑
}
};
遵循单一职责原则的代码应该是这样的:
cpp
class AuthenticationManager {
public:
void AuthenticateUser(const std::string& username, const std::string& password) {
// 实现用户认证逻辑
}
};
class AuthorizationManager {
public:
void AuthorizeUser(const std::string& username, const std::string& resource) {
// 实现用户授权逻辑
}
};
在这个改进的例子中,我们将AuthManager
类的职责拆分成了两个独立的类:AuthenticationManager
和AuthorizationManager
。每个类都只有一个明确的职责,这使得代码更加清晰、可维护,并且降低了类之间职责的耦合性。
2. 开闭原则(Open/Closed Principle, OCP)
含义:软件实体(类、模块、函数等)应该是可扩展的,但不可修改的。也就是说,当需要添加新功能时,应该通过扩展现有代码来实现,而不是修改现有代码。
例子:假设我们有一个简单的日志系统,它可以记录不同级别的日志信息。最初,系统只支持记录"信息"和"警告"级别的日志。后来,我们需要添加一个新的日志级别"错误",而不修改已有的代码。
违背开闭原则的C++例子1
cpp
#include <iostream>
#include <string>
// 日志级别枚举
enum class LogLevel {
Info,
Warning
};
// 日志记录器基类
class Logger {
public:
virtual ~Logger() = default;
virtual void log(LogLevel level, const std::string& message) const = 0;
};
// 控制台日志记录器实现
class ConsoleLogger : public Logger {
public:
void log(LogLevel level, const std::string& message) const override {
switch (level) {
case LogLevel::Info:
std::cout << "[Info] " << message << std::endl;
break;
case LogLevel::Warning:
std::cout << "[Warning] " << message << std::endl;
break;
// 注意:这里没有处理新的日志级别,因为我们假设这是在添加新级别之前的代码
}
}
};
// 客户端代码,使用日志记录器
void useLogger(Logger& logger, LogLevel level, const std::string& message) {
logger.log(level, message);
}
int main() {
ConsoleLogger consoleLogger;
useLogger(consoleLogger, LogLevel::Info, "This is an info message.");
useLogger(consoleLogger, LogLevel::Warning, "This is a warning message.");
// 现在,如果我们想添加一个新的日志级别"错误",我们应该怎么做呢?
// 遵循开闭原则,我们不应该修改已有的ConsoleLogger类。
// 取而代之的是,我们可以扩展LogLevel枚举和ConsoleLogger类(在实际应用中)。
// 但由于这个例子是静态的,我们不能在这里动态地添加新的枚举值或修改类。
// 因此,这个例子实际上是不完整的,它只是为了说明如果我们能够修改代码,我们应该如何扩展它。
// 在真实场景中,我们可能会使用其他设计模式(如策略模式)或编程技术(如插件系统)来实现真正的开闭原则。
return 0;
}
注意 :上面的例子实际上并没有完全遵守开闭原则,因为我们没有展示如何不修改ConsoleLogger
类来添加新的日志级别。在真实的项目中,我们可能会使用如下方法:
- 使用配置文件或数据库来定义日志级别,而不是硬编码在枚举中。
- 使用策略模式或插件架构来允许动态添加新的日志处理器。
- 利用反射或类似的机制来在运行时动态识别和调用相应的日志处理方法。
由于C++语言本身的静态特性,完全实现开闭原则可能需要一些高级技巧和额外的设计考虑。
违背开闭原则的C++例子2
假设我们没有使用上面的抽象基类Logger
和虚函数,而是直接在ConsoleLogger
类中实现了所有日志级别的处理逻辑。当需要添加新的日志级别时,我们就不得不修改ConsoleLogger
类的代码。
cpp
#include <iostream>
#include <string>
// 日志级别枚举(同上)
enum class LogLevel { /* ... */ };
// 控制台日志记录器实现(没有使用抽象基类和虚函数)
class ConsoleLogger {
public:
void logInfo(const std::string& message) const {
std::cout << "[Info] " << message << std::endl;
}
void logWarning(const std::string& message) const {
std::cout << "[Warning] " << message << std::endl;
}
// 假设这里原本没有logError方法,现在我们需要添加它。
// 这将违反开闭原则,因为我们需要修改ConsoleLogger类的代码。
void logError(const std::string& message) const {
std::cout << "[Error] " << message << std::endl;
}
};
// 客户端代码(直接使用具体的日志记录器类)
int main() {
ConsoleLogger consoleLogger;
consoleLogger.logInfo("This is an info message.");
consoleLogger.logWarning("This is a warning message.");
// 现在我们需要添加对错误日志的支持,所以我们必须修改ConsoleLogger类来添加logError方法。
consoleLogger.logError("This is an error message."); // 这将违反开闭原则。
return 0;
}
在这个例子中,当我们需要添加新的日志级别时,我们不得不修改ConsoleLogger
类来添加新的logError
方法。这就违反了开闭原则的要求。
为了完全遵守开闭原则,我们需要设计一种扩展性更好的日志系统。在C++中,我们可以通过使用抽象基类、虚函数以及可能的工厂模式来实现这一目标。
遵守开闭原则的C++例子
下面是一个改进的例子,展示了如何遵守开闭原则:
cpp
#include <iostream>
#include <map>
#include <string>
#include <memory>
// 日志级别枚举
enum class LogLevel {
Info,
Warning,
Error // 新添加的日志级别
};
// 日志记录器抽象基类
class Logger {
public:
virtual ~Logger() = default;
virtual void log(LogLevel level, const std::string& message) const = 0;
// 其他可能的共享功能...
};
// 控制台日志记录器
class ConsoleLogger : public Logger {
public:
void log(LogLevel level, const std::string& message) const override {
switch (level) {
case LogLevel::Info:
std::cout << "[Info] " << message << std::endl;
break;
case LogLevel::Warning:
std::cout << "[Warning] " << message << std::endl;
break;
case LogLevel::Error: // 处理新添加的日志级别
std::cout << "[Error] " << message << std::endl;
break;
// 其他可能的日志级别...
}
}
};
// 日志记录器工厂类
class LoggerFactory {
public:
static std::unique_ptr<Logger> createLogger(const std::string& type) {
if (type == "console") {
return std::make_unique<ConsoleLogger>();
}
// 可以在这里扩展其他类型的日志记录器,而不需要修改已有的代码
throw std::invalid_argument("Invalid logger type");
}
};
// 客户端代码
int main() {
// 通过工厂创建一个控制台日志记录器
auto consoleLogger = LoggerFactory::createLogger("console");
// 使用日志记录器记录不同级别的日志信息
consoleLogger->log(LogLevel::Info, "This is an info message.");
consoleLogger->log(LogLevel::Warning, "This is a warning message.");
consoleLogger->log(LogLevel::Error, "This is an error message."); // 新添加的日志级别可以正常使用
return 0;
}
在这个例子中,我们定义了一个抽象基类Logger
和一个派生类ConsoleLogger
,它们通过虚函数log
来实现多态。这样,我们就可以在不修改ConsoleLogger
类的情况下添加新的日志级别。我们还定义了一个LoggerFactory
工厂类来创建不同类型的日志记录器,这使得我们的系统更加灵活和可扩展。
现在,如果我们想添加一个新的日志记录器类型(比如文件日志记录器),我们只需要创建一个新的类(比如FileLogger
),继承自Logger
,并实现log
函数。然后,在LoggerFactory
类中添加一个新的条件分支来创建FileLogger
对象即可。整个过程不需要修改已有的代码,完全符合开闭原则的要求。
3. 里氏替换原则(Liskov Substitution Principle, LSP)
含义:在软件中,如果 S 是 T 的子类型,则程序中使用 T 类型的对象的地方都可以用 S 类型的对象来替换,而不会改变程序的期望行为。这要求子类型必须能够完全替代其父类型。
例子 :假设我们有一个Vehicle
基类和一个派生类Bicycle
。Vehicle
类有一个StartEngine()
方法。但是,自行车没有引擎,所以这个方法在Bicycle
类中没有意义。
违反里氏替换原则的代码可能是这样的:
cpp
class Vehicle {
public:
virtual void StartEngine() = 0;
// ... 其他通用方法
};
class Bicycle : public Vehicle {
public:
void StartEngine() override { /* 什么也不做,因为自行车没有引擎 */ }
// ... Bicycle特有的方法
};
这个例子违反了里氏替换原则,因为Bicycle
不能正确地实现Vehicle
的StartEngine()
方法。正确的做法是不让Bicycle
继承自Vehicle
,或者重新设计类层次结构以反映实际情况。例如,可以创建一个没有StartEngine()
方法的更通用的基类(如Transport
),并让Vehicle
和Bicycle
都继承自这个基类。或者,如果继承关系确实存在,那么应该避免在基类中定义那些不能被子类正确实现的方法。
上面的例子展示了违反里氏替换原则的情况。为了遵循这个原则,我们需要重新设计类层次结构,确保子类型能够正确地替换父类型。
下面是一个遵循里氏替换原则的改进例子:
cpp
// 定义一个更通用的基类,不包含特定于有引擎车辆的方法
class Transport {
public:
virtual void Move() = 0; // 所有交通工具都可以移动
// ... 其他通用方法
};
// Vehicle类继承自Transport,并添加与引擎相关的方法
class Vehicle : public Transport {
protected:
Engine engine; // 假设有一个Engine类表示引擎
public:
void StartEngine() { /* 启动引擎的代码 */ }
void Move() override { /* 使用引擎移动的代码 */ }
// ... 其他与车辆相关的方法
};
// Bicycle类也继承自Transport,但不包含StartEngine方法
class Bicycle : public Transport {
public:
void Move() override { /* 骑自行车移动的代码 */ }
// ... 其他与自行车相关的方法
};
// 现在我们可以使用Transport指针或引用来操作Vehicle和Bicycle对象,
// 而不需要担心调用不适合的方法(比如StartEngine)
void UseTransport(Transport& transport) {
transport.Move(); // 无论是Vehicle还是Bicycle,这个方法都是安全的
// 注意:我们不能在这里调用StartEngine(),因为不是所有Transport都有引擎
}
在这个改进的例子中,我们创建了一个更通用的基类Transport
,它只包含所有交通工具共有的方法,比如Move()
。然后,Vehicle
类继承自Transport
并添加了与引擎相关的方法,而Bicycle
类也继承自Transport
但没有引擎相关的方法。这样,我们就可以安全地使用Transport
类型的引用来操作任何交通工具,而不用担心调用不适当的方法。
4. 接口隔离原则(Interface Segregation Principle, ISP)
含义:客户端不应该依赖于它不需要的接口。一个类对另一个类的依赖应该是最小的。这通常意味着将大接口拆分成更小、更具体的接口,这样客户端只需要知道和使用它们感兴趣的方法。
例子:假设我们有一个打印机接口,它包含了打印、扫描和传真等多种功能的方法。但是,有些打印机只支持打印功能。
违反接口隔离原则的代码可能是这样的:
cpp
// 一个包含多种功能的打印机接口
class MultiFunctionPrinter {
public:
virtual void Print(const Document& doc) = 0;
virtual void Scan(const Document& doc) = 0;
virtual void Fax(const Document& doc, const FaxInfo& info) = 0;
// ... 其他方法
};
// 一个只支持打印功能的打印机类,但不得不实现所有接口方法
class SimplePrinter : public MultiFunctionPrinter {
public:
void Print(const Document& doc) override { /* 实现打印 */ }
void Scan(const Document& doc) override { /* 无法实现,可能抛出异常或什么都不做 */ }
void Fax(const Document& doc, const FaxInfo& info) override { /* 无法实现 */ }
// ... 其他方法的空实现或异常抛出
};
遵循接口隔离原则的代码应该是这样的:
cpp
// 将功能拆分成不同的接口
class Printer {
public:
virtual void Print(const Document& doc) = 0;
// ... 其他与打印相关的方法
};
class Scanner {
public:
virtual void Scan(const Document& doc) = 0;
// ... 其他与扫描相关的方法
};
class FaxMachine {
public:
virtual void Fax(const Document& doc, const FaxInfo& info) = 0;
// ... 其他与传真相关的方法
};
// 现在我们可以创建只支持打印功能的打印机类,而不需要实现其他无关的方法
class SimplePrinter : public Printer {
public:
void Print(const Document& doc) override { /* 实现打印 */ }
// ... 其他与打印相关的方法的实现
};
在这个改进的例子中,我们将大接口MultiFunctionPrinter
拆分成了三个小接口:Printer
、Scanner
和FaxMachine
。这样,SimplePrinter
类就只需要实现它真正支持的打印功能,而不需要关心扫描和传真等其他无关的功能。这减少了类之间的不必要依赖,提高了代码的可维护性和可扩展性。
5. 依赖倒置原则(Dependency Inversion Principle, DIP)
含义:高层模块不应该依赖于低层模块,它们都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
例子:假设我们有一个读取文件的类和一个处理文件内容的类。如果处理类直接依赖于具体的读取类,那么它就违反了依赖倒置原则。
违反依赖倒置原则的代码可能是这样的:
cpp
// 具体的读取类
class FileReader {
public:
std::string ReadFile(const std::string& path) {
// 实现文件读取
return "file content";
}
};
// 处理类直接依赖于FileReader
class FileProcessor {
private:
FileReader reader;
public:
void ProcessFile(const std::string& path) {
std::string content = reader.ReadFile(path);
// 处理文件内容
}
};
遵循依赖倒置原则的代码应该是这样的:
cpp
// 定义读取文件的抽象接口
class IReader {
public:
virtual std::string Read(const std::string& source) = 0;
};
// 具体的读取类实现抽象接口
class FileReader : public IReader {
public:
std::string Read(const std::string& path) override {
// 实现文件读取
return "file content";
}
};
// 处理类依赖于抽象接口而不是具体实现
class FileProcessor {
private:
IReader* reader; // 使用指针或智能指针以便动态绑定
public:
FileProcessor(IReader* rdr) : reader(rdr) {} // 通过构造函数注入依赖
void ProcessFile(const std::string& path) {
std::string content = reader->Read(path); // 使用接口方法
// 处理文件内容
}
};
在这个改进的例子中,我们创建了一个抽象接口IReader
,并让FileReader
类实现这个接口。然后,我们将FileProcessor
类的依赖从具体的FileReader
类改为IReader
接口。这样,我们就可以轻松地替换不同的读取实现,而不需要修改FileProcessor
类的代码。
6. 迪米特原则(Law of Demeter)
含义:迪米特原则(也称为最少知识原则,Law of Demeter, LoD)是面向对象设计中的一个重要原则,它强调一个对象应当对其他对象保持最少的了解,使得系统各部分之间的耦合度降低。具体来说,一个类应该尽量减少与其他类的直接交互,只与它的直接朋友(即直接与之关联的对象)通信,而不是与"陌生人"通信。
例子 :假设我们有一个简单的图形绘制系统,其中有一个Shape
基类,以及两个派生类Circle
和Rectangle
。我们还有一个Drawer
类,负责绘制这些形状。按照迪米特原则,Drawer
类应该只与Shape
接口交互,而不必关心具体是哪种形状。
遵守迪米特原则的例子(C++)
cpp
#include <iostream>
// Shape基类
class Shape {
public:
virtual void draw() const = 0;
};
// Circle类
class Circle : public Shape {
public:
void draw() const override {
std::cout << "Drawing a circle." << std::endl;
}
};
// Rectangle类
class Rectangle : public Shape {
public:
void draw() const override {
std::cout << "Drawing a rectangle." << std::endl;
}
};
// Drawer类
class Drawer {
public:
void drawShape(const Shape& shape) {
shape.draw(); // 只调用Shape接口的方法
}
};
int main() {
Circle circle;
Rectangle rectangle;
Drawer drawer;
drawer.drawShape(circle);
drawer.drawShape(rectangle);
return 0;
}
在这个例子中,Drawer
类只与Shape
接口交互,它不需要知道具体是Circle
还是Rectangle
,因此遵守了迪米特原则。
违背迪米特原则的例子(C++)
cpp
#include <iostream>
// Circle类
class Circle {
public:
void draw() const {
std::cout << "Drawing a circle." << std::endl;
}
};
// Rectangle类
class Rectangle {
public:
void draw() const {
std::cout << "Drawing a rectangle." << std::endl;
}
};
// Drawer类,直接依赖于具体的Circle和Rectangle类
class Drawer {
public:
void drawCircle(const Circle& circle) {
circle.draw();
}
void drawRectangle(const Rectangle& rectangle) {
rectangle.draw();
}
};
int main() {
Circle circle;
Rectangle rectangle;
Drawer drawer;
drawer.drawCircle(circle);
drawer.drawRectangle(rectangle);
return 0;
}
在这个例子中,Drawer
类直接与Circle
和Rectangle
类交互,这意味着它对这些具体类的实现有了过多的了解。如果未来需要添加新的形状或者修改现有形状的实现,Drawer
类可能也需要进行相应的修改,这增加了系统的耦合度。通过引入Shape
基类并使用多态,我们可以避免这种情况,从而遵守迪米特原则。