继承不是“拿来用“:is-a 关系与组合

文章目录

  • 引言
  • [一、C 的"继承":结构体嵌套](#一、C 的"继承":结构体嵌套)
  • [二、C++ 的继承基础语法](#二、C++ 的继承基础语法)
    • [2.1 最简单的继承](#2.1 最简单的继承)
    • [2.2 `public` 继承意味着什么](#2.2 public 继承意味着什么)
  • [三、is-a 的铁律:何时才能用继承](#三、is-a 的铁律:何时才能用继承)
    • [3.1 简单的判断标准](#3.1 简单的判断标准)
    • [3.2 反例:Square 应该继承 Rectangle 吗?](#3.2 反例:Square 应该继承 Rectangle 吗?)
  • 四、继承最常见的误用:为了复用代码
    • [4.1 一个典型的错误](#4.1 一个典型的错误)
    • [4.2 正确的做法:组合](#4.2 正确的做法:组合)
  • 五、继承和组合的实战对比
    • [5.1 场景:设计一个"带颜色的矩形"](#5.1 场景:设计一个"带颜色的矩形")
    • [5.2 决策速查表](#5.2 决策速查表)
  • 六、继承的其他细节
    • [6.1 派生类的构造与析构顺序](#6.1 派生类的构造与析构顺序)
    • [6.2 派生类中调用基类方法](#6.2 派生类中调用基类方法)
    • [6.3 三种继承方式速查](#6.3 三种继承方式速查)
  • 总结

本系列为《C++深度修炼:基础、STL源码与多线程实战》第5篇

前置条件:理解 class 封装(第2篇)、构造函数(第3篇)、成员函数(第4篇)

引言

很多 C 程序员学会 C++ 的继承语法之后,第一反应是:"太好了!我有一个现成的类,里面大部分代码我都要用,继承它改几个方法就行了。"

这是个危险的直觉。 把继承当成"代码复用工具",是 C++ 中最常见的面向对象设计错误。

继承的真正含义只有一个:is-a(是一个)Dog 继承 Animal,是因为 Dog is an Animal------狗就是动物。不是因为 Animal 里面有一堆好用的函数,省得你自己写。

本文用 C 程序员的视角,从 struct 嵌套开始,解释继承的本质、误用场景,以及什么时候应该用**组合(composition)**替代继承。


一、C 的"继承":结构体嵌套

在 C 中,"复用"一个结构体的方式只有一种:嵌套

cpp 复制代码
// embed.c
#include <stdio.h>

struct Person {
    char name[32];
    int age;
};

struct Employee {
    struct Person person;   // 嵌套 Person
    int employee_id;
    double salary;
};

int main() {
    struct Employee emp;
    snprintf(emp.person.name, 32, "张三");
    emp.person.age = 30;
    emp.employee_id = 1001;
    emp.salary = 50000.0;

    printf("%s, %d岁, 工号%d, 月薪%.0f\n",
           emp.person.name, emp.person.age, emp.employee_id, emp.salary);
}

这就是组合(Composition) ------ Employee has a Person(员工有一个人信息),不是"员工是一个人"。

在 C++ 中,你有两种方式表达这个关系:继承(is-a)和组合(has-a)。用对还是用错,是区分设计水平的分水岭。


二、C++ 的继承基础语法

2.1 最简单的继承

cpp 复制代码
#include <iostream>
#include <string>

class Animal {
public:
    Animal(const std::string &name) : name_(name) {}

    void eat() { std::cout << name_ << " 吃东西\n"; }
    void sleep() { std::cout << name_ << " 睡觉\n"; }

    std::string name() const { return name_; }

private:
    std::string name_;
};

// Dog is-a Animal
class Dog : public Animal {
public:
    Dog(const std::string &name) : Animal(name) {}  // 委托基类构造

    void bark() { std::cout << name() << " 汪汪叫\n"; }
};

int main() {
    Dog d("旺财");
    d.eat();    // 继承自 Animal
    d.sleep();  // 继承自 Animal
    d.bark();   // Dog 自己的方法
}
text 复制代码
$ g++ -std=c++17 dog.cpp && ./a.out
旺财 吃东西
旺财 睡觉
旺财 汪汪叫

2.2 public 继承意味着什么

cpp 复制代码
class Dog : public Animal { /* ... */ };
//            ^^^^^^

public 继承的含义是:"Dog is an Animal" 这个事实对全世界公开。 具体表现为:

基类的访问权限 在 public 继承后,派生类中 外部代码中
public 成员 仍然是 public 可通过派生类对象访问
protected 成员 仍然是 protected 不可访问
private 成员 不可访问(但存在于对象中) 不可访问
cpp 复制代码
class Base {
public:    int pub_;
protected: int prot_;
private:   int priv_;  // 派生类也看不见
};

class Derived : public Base {
public:
    void test() {
        pub_ = 1;   // ✅
        prot_ = 2;  // ✅ 派生类可以访问 protected
        // priv_ = 3; // ❌ 派生类也不能访问基类的 private
    }
};

int main() {
    Derived d;
    d.pub_ = 10;   // ✅ public 继承后仍然是 public
    // d.prot_ = 20;  // ❌ protected 对外部始终不可见
    // d.priv_ = 30;  // ❌ private 永远不可见
}

三、is-a 的铁律:何时才能用继承

3.1 简单的判断标准

用一句话问自己:"派生类是否在任何场景下都可以替换基类?"

如果答案是"是"------用继承。如果答案是"大部分可以,但有些场景不太对"------不能用继承。

cpp 复制代码
// ✅ 好的继承:Rectangle is a Shape
class Shape {
public:
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

class Rectangle : public Shape {
public:
    Rectangle(double w, double h) : w_(w), h_(h) {}
    double area() const override { return w_ * h_; }
private:
    double w_, h_;
};

// Rectangle 在任何需要 Shape 的场景下都能用------is-a 成立
void print_area(const Shape &s) {
    std::cout << "面积: " << s.area() << '\n';
}

3.2 反例:Square 应该继承 Rectangle 吗?

这是最经典的继承陷阱。直觉上"正方形是特殊的矩形",但:

cpp 复制代码
class Rectangle {
public:
    Rectangle(double w, double h) : w_(w), h_(h) {}
    virtual void set_width(double w)  { w_ = w; }
    virtual void set_height(double h) { h_ = h; }
    double area() const { return w_ * h_; }
protected:
    double w_, h_;
};

class Square : public Rectangle {
public:
    Square(double side) : Rectangle(side, side) {}

    void set_width(double w) override {
        w_ = w;
        h_ = w;  // 保持正方形:宽高同步
    }
    void set_height(double h) override {
        w_ = h;
        h_ = h;  // 保持正方形:宽高同步
    }
};

// 问题来了:
void stretch(Rectangle &r) {
    r.set_width(r.area() / r.w_ * 2);  // 假设 w_ 可访问
    // 调用者以为 r 是个普通矩形,单独调宽没问题
    // 但如果 r 实际是 Square,set_width 会连带改高度------调用者不知道!
}

Square is NOT a Rectangle ------在可变的设定下,正方形不能自由改变宽高,因此不能在需要 Rectangle 的地方无差别地替换。让 Square 继承 Rectangle 是对 is-a 的违反。

正确的做法:两者各自继承 Shape,互不依赖。


四、继承最常见的误用:为了复用代码

4.1 一个典型的错误

cpp 复制代码
// ❌ 错误示范:为了复用而继承
class CsvWriter {
public:
    void open(const std::string &path) { /* ... */ }
    void write_row(const std::vector<std::string> &row) { /* ... */ }
    void close() { /* ... */ }
};

// 我想要一个写 JSON 的功能,加上 CSV 里 open/close ------ 继承吧?
class JsonWriter : public CsvWriter {  // 错!JsonWriter is NOT a CsvWriter
public:
    void write_json(const std::string &json) {
        // 调用 write_row 来输出?完全用不上......
    }
};

JsonWriter 不是 CsvWriter------它们本质上是两个独立的写器。把 CsvWriter 当"工具包"继承过来,就是典型的"为了复用而继承"。

4.2 正确的做法:组合

cpp 复制代码
// ✅ 正确:组合
class FileHandle {  // 提取共享的"文件打开关闭"职责
public:
    void open(const std::string &path) { /* ... */ }
    void close() { /* ... */ }
};

class CsvWriter {
public:
    CsvWriter() : file_(new FileHandle()) {}
    void write_row(const std::vector<std::string> &row) { /* 用 file_->write() */ }
private:
    std::shared_ptr<FileHandle> file_;  // has-a,不是 is-a
};

class JsonWriter {
public:
    JsonWriter() : file_(new FileHandle()) {}
    void write_json(const std::string &json) { /* 用 file_->write() */ }
private:
    std::shared_ptr<FileHandle> file_;  // has-a,不是 is-a
};

从"继承以复用"切换到"组合以复用"------这条原则在 GoF 的《设计模式》中被总结为:

"优先使用对象组合而不是类继承。"(Favor object composition over class inheritance.)


五、继承和组合的实战对比

5.1 场景:设计一个"带颜色的矩形"

方案 A:继承

cpp 复制代码
// ❌ 仅仅为了加个 color 就继承
class ColoredRectangle : public Rectangle {
    std::string color_;
public:
    ColoredRectangle(double w, double h, const std::string &c)
        : Rectangle(w, h), color_(c) {}
};

方案 B:组合

cpp 复制代码
// ✅ Rectangle 干 Rectangle 的事,Color 是额外的属性
class ColoredRectangle {
    Rectangle rect_;
    std::string color_;
public:
    ColoredRectangle(double w, double h, const std::string &c)
        : rect_(w, h), color_(c) {}

    double area() const { return rect_.area(); }
    std::string color() const { return color_; }
};

判断标准:"带颜色的矩形"是不是一种特殊的矩形? 还是说,它的"矩形性"只是它的一个属性?

如果在你的设计中,矩形的所有操作对"带颜色的矩形"都完全适用,那继承也可以。但如果只是"需要一个矩形 + 一个颜色",组合更简单直接。

5.2 决策速查表

场景 用继承 用组合
新类"是"旧类的特化 ---
新类"有"旧类作为组件 ---
需要基类的所有接口原样传递 ---
只需要基类的部分功能 ---
运行时要切换多种实现 ✅(多态) ---
基类的实现细节频繁变化 ---
不确定、说不清楚"是不是" --- ✅(安全默认值)

💡 经验法则 :当你犹豫该用继承还是组合时,先写组合 。如果后来发现到处都在手动转发同样的接口("组合疲劳"),再考虑提取为继承。从组合到继承容易,从继承到组合难。


六、继承的其他细节

6.1 派生类的构造与析构顺序

cpp 复制代码
#include <iostream>

struct Base {
    Base()  { std::cout << "Base()\n"; }
    ~Base() { std::cout << "~Base()\n"; }
};

struct Member {
    Member()  { std::cout << "  Member()\n"; }
    ~Member() { std::cout << "  ~Member()\n"; }
};

class Derived : public Base {
public:
    Derived()  { std::cout << "  Derived()\n"; }
    ~Derived() { std::cout << "  ~Derived()\n"; }
private:
    Member m_;
};

int main() {
    Derived d;
}
text 复制代码
$ g++ -std=c++17 order.cpp && ./a.out
Base()
  Member()
  Derived()
  ~Derived()
  ~Member()
~Base()

构造顺序 :基类 → 成员(按声明顺序) → 派生类自身
析构顺序:完全逆序(派生类自身 → 成员 → 基类)

这和"先打地基再盖房子,拆房先拆顶再拆地基"是一个道理。

6.2 派生类中调用基类方法

cpp 复制代码
class Base {
public:
    void foo() { std::cout << "Base::foo\n"; }
};

class Derived : public Base {
public:
    void foo() {
        std::cout << "Derived::foo\n";
        Base::foo();  // 显式调用基类版本
    }
};

不加 Base:: 的话,foo() 会递归调用自己------因为派生类的名字遮蔽(hide)了基类的同名函数。

6.3 三种继承方式速查

继承方式 基类 public → 派生类 基类 protected → 派生类 典型用途
public public protected "is-a" 关系(最常用)
protected protected protected "对子类公开,对外保密"
private private private "用基类来实现"(本质上就是组合的替代语法)
cpp 复制代码
// private 继承:等于"用基类的代码,但不承认 is-a 关系"
class MyStack : private std::vector<int> {
public:
    void push(int v) { push_back(v); }
    void pop() { pop_back(); }
    int top() const { return back(); }
    using std::vector<int>::empty;
    using std::vector<int>::size;
};
// MyStack 不是 vector<int>------它只是用 vector 来实现 stack
// 外部代码不能写: MyStack *p = new std::vector<int>;  // ❌

⚠️ private 继承在大多数场景下可以用一个私有成员变量替代。优先考虑组合;只有当需要访问基类的 protected 成员或覆盖虚函数时才用 private 继承。


总结

继承是 C++ 中最容易被滥用的机制,因为它看起来太方便了------在类名后面加个 : public Base 就能"免费"获得大量代码。

记住这条核心原则:继承表达的是 is-a(是一个),不是 has-a(有一个),更不是"我想要你里面的函数"

  1. public 继承表达严格的 is-a 关系------派生类必须能在任何场景下替换基类
  2. 组合(成员变量)表达 has-a 关系------这是默认的安全选择
  3. 有疑虑时,先写组合------从组合迁移到继承比重构错误的继承体系容易得多
  4. 构造析构顺序是基类→成员→派生类(析构逆序),这决定了对象从"毛坯"到"精装"的搭建过程

第1章到此结束。下一篇开始,我们进入第2章------命名空间、头文件和 C++ 的基础设施升级,让代码从"能编译"进化到"有组织"。


📝 动手练习

  1. 设计一个"鸟"的继承体系------Bird 基类,SparrowPenguinEagle 作为派生类。试着给 Bird 加一个 fly() 方法,然后发现企鹅不会飞------重新思考设计
  2. 把第2篇的 BankAccount 扩展为 SavingsAccount(有利率)和 CheckingAccount(有透支额度),验证 is-a 是否成立
  3. 找一段自己以前用继承写的代码,审视是否误用了"为了复用而继承"------如果是,用组合改写
相关推荐
.小小陈.2 小时前
Linux 多线程进阶:线程互斥、同步、线程池、死锁与线程安全、读写锁、自旋锁
linux·开发语言·c++
lingran__2 小时前
C++入门基础
开发语言·c++
代码改善世界2 小时前
【C++进阶】二叉搜索树
java·数据结构·c++
春蕾夏荷_7282977252 小时前
c++ 编译abseil-cpp
c++·abseil-cpp
ComputerInBook3 小时前
C++ 17 相比 C++ 14 新增之特征
开发语言·c++·c++ 17
Peter·Pan爱编程4 小时前
引用:比指针更安全的别名
c++·指针·引用·c++基础
m0_502724954 小时前
golang 、java、c++、javascript 语言switch case异同
java·javascript·c++·golang
我命由我123454 小时前
Android Framework P1 - 低配学习 Framework 方案、开机启动 Init 进程
android·c语言·c++·学习·android jetpack·android-studio·android runtime
许长安4 小时前
互斥锁、自旋锁、读写锁使用场景以及底层实现
c++·经验分享·笔记