VoidMatrix_通关设计模式-附02-里氏替换:参数与返回值

25 年 3 月 25 日 星期二
1615 字
9 分钟

在里氏替换原则(LSP)中,有两条常见的“方法签名约束”往往会让人感到方向相反、令人困惑:

  1. (对参数)子类在重写父类方法时,参数类型必须与父类保持一致或更“宽”(更抽象、父类型)
  2. (对返回值)子类在重写父类方法时,返回值类型必须与父类保持一致或更“窄”(更具体、子类型)

从“方法的调用与运行”角度去理解其原理,会比较直观:


一、从函数调用者角度来理解

简要总结:

  • 参数: 调用者会给你(被调用方)什么?如果子类只能接受更加具体的参数,调用者原本传的东西可能就无法调用了。
  • 返回值: 调用者会从你(被调用方)那里得到什么?如果子类只返回更加抽象的东西,调用者想用的功能可能就消失了;反之,返回更具体则不会影响调用者使用原先的接口。

为了更形象,我们举一个有父类 Father 与子类 Son 的场景:

cpp
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 {
    // 假设是一切生物的超类
};

父类

cpp
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)
    因为 CreatureAnimal 的父类,所以它能接受“更广泛”的输入,包括任何 Animal,也就自然能接受一个 Animal

假设子类这样写:

cpp
class Son : public Father {
public:
    // 参数更抽象(假设我们允许这样设计)
    // 返回值更具体
    Dog* doSomething(Creature* param) {
        std::cout << "Son is doing something with a Creature" << std::endl;
        return new Dog();
    }
};

注意:
在实际的 C++ 中,子类重写父类函数时必须保持函数签名完全一致,除非使用模板或重载机制。这个例子是用于说明里氏替换原则中参数/返回值变换的语义方向

为什么不能“更具体”?

如果子类写成:

cpp
class Son : public Father {
public:
    Animal* doSomething(Dog* param) override {
        std::cout << "Son is doing something with a Dog" << std::endl;
        return new Animal();
    }
};

那当有人这样用时:

cpp
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*,那么在调用者那里就出问题了:

cpp
// 父类承诺:doSomething(...) 返回 Animal*
Animal* result = fatherRef->doSomething(someAnimal);

// 如果 fatherRef 实际上是 new Son(),返回类型却是 Creature*
Creature* result = sonRef->doSomething(someAnimal);

在调用者期待的场景里,他希望拿到一个 Animal*,以便调用 result->makeSound() 这样的操作;可如果子类实际上只返回更抽象的 Creature*,那么调用者就没法保证一定能调用 makeSound() 方法——这就是“接口契约”被破坏了。


二、用“替换”过程来检验

你可以想象一段更通用的代码:

cpp
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);    // 应当也能正常执行
}

分析

  1. letFatherWork() 会传入一个 Animal*father->doSomething(...),然后再调用返回结果(类型为 Animal*)的 makeSound() 方法。
  2. 如果 Son 要替换 Father,那就必须能接受“任意一个 Animal*”做参数(不能要求更窄的类型,如 Dog*),并且必须返回“至少是一个 Animal*”(不能返回更抽象的类型,如 Creature*),才能保证 letFatherWork() 里那句 result->makeSound() 不会出错。

这就是我们常说的:

  • 对参数来说:子类必须能接收“同样或更泛化”的类型。
  • 对返回值来说:子类必须能返回“同样或更具体”的类型。

方向之所以相反,是因为:

  • 输入参数来自调用者,父类越“宽”,能处理的输入越多。子类不能把它变窄,否则原本可行的输入就突然不可行了。
  • 输出(返回)调用者,父类承诺返回某种类型,如果子类返回更具体的类型(是父类的子类),调用者并不会失去原本的功能(反而获得更多)。反之,若子类返回更抽象,会让调用者的后续操作失效或难以处理。

三、总结

  1. 参数类型:相同或更抽象(父类型)
    • 这是所谓的逆变(contravariance)概念:子类的方法应该“兼容”更多范围的输入,不能只处理更具体的东西,否则会破坏替换。
  2. 返回值类型:相同或更具体(子类型)
    • 这是所谓的协变(covariance)概念:子类可以给调用者返回“更细分”的对象,而不会破坏既有的使用方式。

从运行调用的角度看,这两条规则正好是反方向,是因为一个是“我能接受什么东西”,一个是“我能给你什么东西”。只有同时遵守这两个方向,才能确保父类在任何地方都能被子类替换,而不出现类型错误或功能丧失。

文章标题:VoidMatrix_通关设计模式-附02-里氏替换:参数与返回值

文章作者:DWHITE

文章链接:https://dr9k69ai79.github.io/MyBlog/posts/voidmatrix_通关设计模式/a02_里氏替换_参数与返回值[复制]

最后修改时间:


商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
本文采用CC BY-NC-SA 4.0进行许可。