在里氏替换原则(LSP)中,有两条常见的“方法签名约束”往往会让人感到方向相反、令人困惑:
- (对参数)子类在重写父类方法时,参数类型必须与父类保持一致或更“宽”(更抽象、父类型)
- (对返回值)子类在重写父类方法时,返回值类型必须与父类保持一致或更“窄”(更具体、子类型)
从“方法的调用与运行”角度去理解其原理,会比较直观:
一、从函数调用者角度来理解
简要总结:
- 参数: 调用者会给你(被调用方)什么?如果子类只能接受更加具体的参数,调用者原本传的东西可能就无法调用了。
- 返回值: 调用者会从你(被调用方)那里得到什么?如果子类只返回更加抽象的东西,调用者想用的功能可能就消失了;反之,返回更具体则不会影响调用者使用原先的接口。
为了更形象,我们举一个有父类 Father
与子类 Son
的场景:
class Animal {
public:
virtual void makeSound() {
std::cout << "Some animal sound" << std::endl;
}
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void makeSound() override {
std::cout << "Woof!" << std::endl;
}
};
// 更抽象的类型
class Creature {
// 假设是一切生物的超类
};
父类
class Father {
public:
// 接受一个 Animal 指针作为参数,返回一个 Animal 指针
virtual Animal* doSomething(Animal* param) {
std::cout << "Father is doing something with an Animal" << std::endl;
return new Animal();
}
virtual ~Father() = default;
};
子类
我们考虑如何正确“重写”这个方法,使子类满足 LSP 要求。
1. 子类方法的参数类型:与父类一致或更抽象
- 与父类一致:
Animal* doSomething(Animal* param)
- 更抽象(更父):比如
Creature* doSomething(Creature* param)
。
因为Creature
是Animal
的父类,所以它能接受“更广泛”的输入,包括任何Animal
,也就自然能接受一个Animal
。
假设子类这样写:
class Son : public Father {
public:
// 参数更抽象(假设我们允许这样设计)
// 返回值更具体
Dog* doSomething(Creature* param) {
std::cout << "Son is doing something with a Creature" << std::endl;
return new Dog();
}
};
注意:
在实际的 C++ 中,子类重写父类函数时必须保持函数签名完全一致,除非使用模板或重载机制。这个例子是用于说明里氏替换原则中参数/返回值变换的语义方向。
为什么不能“更具体”?
如果子类写成:
class Son : public Father {
public:
Animal* doSomething(Dog* param) override {
std::cout << "Son is doing something with a Dog" << std::endl;
return new Animal();
}
};
那当有人这样用时:
void test(Father* fatherRef) {
Animal* a = new Animal();
fatherRef->doSomething(a);
}
// 如果 fatherRef 实际上是 new Son(),那么就变成:
Son son;
test(&son); // ❌
这会报错或出现未定义行为:因为 Son
的方法期望的参数是 Dog*
,而我们传了一个 Animal*
。这就破坏了“能用父类的地方都能换用子类”的约定。
2. 子类方法的返回值类型:与父类一致或更具体
- 与父类一致:
Animal* doSomething(...)
- 更具体(更子):如果父类返回
Animal*
,子类可以返回Dog*
。
子类的返回值改为 Dog*
时,对于原本“需要父类的地方”并不会出问题。因为拿到一个 Dog*
,仍可以当作 Animal*
来使用——对调用者来说,这个对象还是一个“动物”,并没有丢失任何“父类接口”里承诺的功能,反而更强大了。
为什么不能“更抽象”?
如果子类的方法返回 Creature*
,那么在调用者那里就出问题了:
// 父类承诺:doSomething(...) 返回 Animal*
Animal* result = fatherRef->doSomething(someAnimal);
// 如果 fatherRef 实际上是 new Son(),返回类型却是 Creature*
Creature* result = sonRef->doSomething(someAnimal);
在调用者期待的场景里,他希望拿到一个 Animal*
,以便调用 result->makeSound()
这样的操作;可如果子类实际上只返回更抽象的 Creature*
,那么调用者就没法保证一定能调用 makeSound()
方法——这就是“接口契约”被破坏了。
二、用“替换”过程来检验
你可以想象一段更通用的代码:
void letFatherWork(Father* father) {
Animal* animal = new Animal();
Animal* result = father->doSomething(animal);
result->makeSound(); // 调用 Animal 的方法
}
int main() {
Father* father = new Father();
letFatherWork(father); // 正常执行
Father* son = new Son();
letFatherWork(son); // 应当也能正常执行
}
分析
letFatherWork()
会传入一个Animal*
给father->doSomething(...)
,然后再调用返回结果(类型为Animal*
)的makeSound()
方法。- 如果
Son
要替换Father
,那就必须能接受“任意一个Animal*
”做参数(不能要求更窄的类型,如Dog*
),并且必须返回“至少是一个Animal*
”(不能返回更抽象的类型,如Creature*
),才能保证letFatherWork()
里那句result->makeSound()
不会出错。
这就是我们常说的:
- 对参数来说:子类必须能接收“同样或更泛化”的类型。
- 对返回值来说:子类必须能返回“同样或更具体”的类型。
方向之所以相反,是因为:
- 输入参数来自调用者,父类越“宽”,能处理的输入越多。子类不能把它变窄,否则原本可行的输入就突然不可行了。
- 输出(返回)给调用者,父类承诺返回某种类型,如果子类返回更具体的类型(是父类的子类),调用者并不会失去原本的功能(反而获得更多)。反之,若子类返回更抽象,会让调用者的后续操作失效或难以处理。
三、总结
- 参数类型:相同或更抽象(父类型)
- 这是所谓的逆变(contravariance)概念:子类的方法应该“兼容”更多范围的输入,不能只处理更具体的东西,否则会破坏替换。
- 返回值类型:相同或更具体(子类型)
- 这是所谓的协变(covariance)概念:子类可以给调用者返回“更细分”的对象,而不会破坏既有的使用方式。
从运行调用的角度看,这两条规则正好是反方向,是因为一个是“我能接受什么东西”,一个是“我能给你什么东西”。只有同时遵守这两个方向,才能确保父类在任何地方都能被子类替换,而不出现类型错误或功能丧失。