从LSP到模板方法模式:模板方法如何保证子类行为一致,好莱坞原则
大家好,我是你们的老朋友。
今天我们来聊一个很有意思的话题——模板方法模式。说实话,这个模式是我个人非常喜欢的一个设计模式。为什么?因为它简单,却解决了一个很核心的问题:如何让子类在扩展功能的同时,不破坏父类定义的整体流程。
嗯,我们先从里氏替换原则(LSP)说起。
里氏替换原则:子类不能“背叛”父类
里氏替换原则,说白了就是一句话:父类能用的地方,换成子类也应该能用。听起来很简单对吧?但我在项目中见过太多违反这个原则的代码了。
举个例子:
class Bird {
public:
virtual void fly() {
cout << "I can fly" << endl;
}
};
class Penguin : public Bird {
public:
void fly() override {
throw runtime_error("Penguin can't fly!");
}
};
你看,企鹅继承了鸟,但企鹅不会飞。如果你写一个函数:
void letBirdFly(Bird& bird) {
bird.fly();
}
传入企鹅对象,程序就崩了。这就是典型的违反LSP。
那么,模板方法模式是怎么解决这个问题的呢?
模板方法模式:好莱坞原则
模板方法模式的核心思想,就是好莱坞原则——"Don't call us, we'll call you"。
什么意思?父类定义好整个算法的骨架,子类只需要实现某些步骤。父类控制流程,子类不能反过来调用父类。
我画了一张图,帮你理解这个关系:
模板方法模式的经典结构
我们来看一个实际的代码例子。假设我们要做一个数据导出功能:
class DataExporter {
public:
// 模板方法:定义导出流程
void exportData() {
openFile();
writeHeader();
writeBody();
writeFooter();
closeFile();
}
virtual ~DataExporter() = default;
protected:
void openFile() {
cout << "Opening file..." << endl;
}
void closeFile() {
cout << "Closing file..." << endl;
}
// 抽象方法:子类必须实现
virtual void writeHeader() = 0;
virtual void writeBody() = 0;
virtual void writeFooter() = 0;
};
class CsvExporter : public DataExporter {
protected:
void writeHeader() override {
cout << "Name,Age,City" << endl;
}
void writeBody() override {
cout << "Alice,30,NYC" << endl;
cout << "Bob,25,LA" << endl;
}
void writeFooter() override {
cout << "Total: 2 records" << endl;
}
};
class JsonExporter : public DataExporter {
protected:
void writeHeader() override {
cout << "[" << endl;
}
void writeBody() override {
cout << " {\"name\":\"Alice\",\"age\":30}," << endl;
cout << " {\"name\":\"Bob\",\"age\":25}" << endl;
}
void writeFooter() override {
cout << "]" << endl;
}
};
关键点:父类的 exportData() 是模板方法,它定义了“打开-写入-关闭”这个不可变的流程。子类只能改变“写入”的具体内容,不能改变流程本身。
钩子方法:给子类一点“选择权”
有时候,我们想让子类在某些步骤上有选择权。比如,有些导出格式需要加密,有些不需要。这时候可以用钩子方法:
class DataExporter {
public:
void exportData() {
openFile();
writeHeader();
writeBody();
writeFooter();
if (needEncryption()) { // 钩子方法
encryptFile();
}
closeFile();
}
protected:
virtual bool needEncryption() {
return false; // 默认不加密
}
virtual void encryptFile() {
// 默认实现为空
}
// ... 其他方法
};
class SecureCsvExporter : public CsvExporter {
protected:
bool needEncryption() override {
return true;
}
void encryptFile() override {
cout << "Encrypting file with AES-256..." << endl;
}
};
你看,钩子方法让子类可以“选择”是否执行某个步骤,但流程控制权仍然在父类手里。
我个人习惯:钩子方法的名字最好以 should、need、can 开头,这样读代码的人一眼就能看出这是个条件判断。
模板方法模式如何保证LSP?
回到我们最开始的问题。模板方法模式为什么能保证子类行为一致?
原因很简单:父类把流程固定死了。子类只能实现某些步骤,不能改变步骤的顺序,也不能跳过关键步骤。
你想想看,如果子类想“背叛”父类,它能做什么?它最多在某个步骤里返回一个空值,或者抛个异常。但流程本身——打开、写入、关闭——它改不了。
这就保证了:任何子类对象,调用 exportData() 的行为都是一致的。不会出现“这个子类先关闭再打开”这种奇葩情况。
| 对比项 | 普通继承 | 模板方法模式 |
|---|---|---|
| 流程控制 | 子类可重写整个方法 | 父类固定流程 |
| LSP符合度 | 低(子类容易破坏) | 高(流程不可变) |
| 扩展方式 | 重写方法 | 实现抽象方法/钩子 |
| 代码复用 | 低 | 高(公共代码在父类) |
实际项目中的避坑指南
我在实际项目中用过很多次模板方法模式,也踩过一些坑。这里分享几个经验:
- 不要滥用钩子方法。钩子太多,子类的行为就不可控了。我一般控制在1-2个钩子。
- 模板方法尽量用
final关键字(C++11以后)。防止子类不小心重写了模板方法本身。 - 抽象方法不要太多。3-5个抽象方法比较合适。太多了,子类实现起来很痛苦。
- 注意命名规范。模板方法名用动词短语,比如
processOrder、buildReport。抽象方法名用doXxx前缀,比如doValidate、doSave。
我曾经在一个项目中,把模板方法设计成了虚函数,结果某个新来的同事直接重写了整个模板方法,把流程改得面目全非。从那以后,我所有模板方法都加上了 final。
总结
模板方法模式,说白了就是:父类定规矩,子类填细节。
它和里氏替换原则的关系很微妙。LSP要求子类不能破坏父类的行为,而模板方法模式通过固定流程,从设计上就杜绝了这种破坏。两者相辅相成。
好莱坞原则听起来很酷,但核心思想很简单:别打电话给我们,我们会打给你。父类掌握控制权,子类安心做自己的事就好。
嗯,今天就聊到这里。记住,设计模式不是银弹,但模板方法模式在处理“固定流程+可变步骤”的场景下,确实是个好选择。