第 19 章

struct、对象大小与继承入门

struct 与 class 的唯一区别是默认访问级别;对象大小由成员变量决定并受对齐影响;继承把基类作为子对象嵌入派生类,public 继承不改变成员的可见性,protected 让派生类可访问但外部不可见,private 继承则把所有继承来的成员降为私有。

Structs, Object Size and First Steps in Inheritance

The only difference between struct and class is the default access level; object size is determined by member variables and affected by alignment; inheritance embeds a base-class subobject inside each derived object, public inheritance preserves visibility, protected lets derived classes in while keeping outsiders out, and private inheritance demotes everything it inherits.

首发于 2022.02.17中 / EN
系统课程cppstructobject-sizeinheritanceaccess-specifiers
语言
字号
目录
篇内目录

本章是 31 小时长视频的第 19 章,对应 22:33:31–24:26:26。它先收尾类的两个细节(struct 与对象大小),再从零建立继承:基类如何嵌入派生类、三种访问说明符如何改变可见性。

这一章在讲什么

三个问题依次展开:

  1. struct 和 class 到底差在哪?答案只有默认访问级别。
  2. 一个对象在内存里占多大?答案是「成员变量之和,加上对齐填充」,函数不计入。
  3. 继承是怎么发生的?派生类对象内部包含一个基类子对象,像套娃一样逐层叠加。

第三条是整章的骨架。理解了「基类部分是真实存在于派生对象内部的子对象」,后面关于访问权限、构造顺序、对象切片的一切就都有了解释。

时间轴

时间内容
22:33struct 与 class:默认访问级别不同,其余相同
22:35用 struct 建模 Point:只有公开数据的轻量类型
22:42对象大小:成员变量之和、指针不计所指内容
22:44边界对齐导致对象比成员之和略大
22:52继承的动机:person → engineer → civil engineer
22:56第一次继承:class Player : public Person
23:00派生类只能访问基类 public 成员;private 成员不可见
23:13用调试器验证派生对象内含基类子对象
23:20protected:派生类可访问,外部仍不可见
23:31三种继承方式:public / protected / private 的映射表
23:36完整演示:Person、Player、Nurse、Engineer、CivilEngineer

struct 与 class:只差一个默认值

class Dog {
    std::string m_name;   // 默认 private
};

struct Cat {
    std::string m_name;   // 默认 public
};

除此之外,二者可以互换:都能有构造函数、成员函数、继承、访问说明符。区别只在于没有写访问说明符时成员的默认级别。

Dog dog1;
// dog1.m_name = "Fluffy";   // 编译错误:private

Cat cat1;
cat1.m_name = "Johnny";      // 可以,struct 默认 public

只要显式写出 public: / private:,两者的行为完全一致。

什么时候该用 struct

课程给出的实用标准:当一个类型只有公开数据、不打算放成员函数时,用 struct。

struct Point {
    double x;
    double y;
};

void print_point(const Point& p) {
    std::cout << "Point[x:" << p.x << ", y:" << p.y << "]\n";
}

Point 不维护任何不变量,也没有行为,公开数据就是它的全部意义。用 struct 让读代码的人一眼看出「这是数据聚合,不是封装对象」。

反过来,一旦需要控制写入、维护不变量(比如半径不能为负),就应该用 class 加 private 成员和 setter。

对象大小:成员之和,加上对齐

class Wrapper {
    int m_a;
    int m_b;
    std::string m_name;
};

打印 sizeof(Wrapper),得到的不是「两个 int 加字符串长度」,而是:

  • 两个 int:4+4=84+4=8 字节;
  • 一个 std::string:在典型实现上是 32 字节,与字符串内容长短无关;
  • 合计约 40 字节(具体取决于标准库实现)。

三个结论:

  1. 函数不计入对象大小。 成员函数属于类,不属于某个对象;一万个对象共享同一份函数代码,没有理由让每个对象为它付费。
  2. 指针只计入指针本身。 若成员是 int*,对象里只存 8 字节地址;它指向多大的数组与对象大小无关。std::string 内部就是指针 + 长度 + 小字符串缓冲区,所以 "hi" 与一千个字符的字符串,sizeof 相同。
  3. 对齐会产生空隙。 多数编译器要求 4 字节的成员落在 4 的倍数地址上。若成员是 char(1 字节)后接 int(4 字节),编译器会在中间补 3 个字节,使 int 对齐。于是
struct Mixed {
    char c;    // 1 字节
    int  i;    // 4 字节,需要 4 字节对齐
};
// sizeof(Mixed) 通常是 8,而不是 5

这条规则解释了为什么调整成员顺序有时能减小对象:把大对齐的成员放前面,空隙更少。

继承:把类建在类之上

设想一个人员体系:Person 是所有「人」的公共部分;Engineer 是「懂工程的 Person」;CivilEngineer 是「专攻土木的 Engineer」。三者是层层特化的关系,正是继承要表达的东西。

class Person {
public:
    Person() = default;
    Person(std::string_view full_name, int age, std::string_view address)
        : m_full_name{full_name}, m_age{age}, m_address{address} {}

    std::string get_full_name() const { return m_full_name; }
    int get_age() const { return m_age; }
    std::string get_address() const { return m_address; }

public:
    std::string m_full_name{"None"};

protected:
    int m_age{0};

private:
    std::string m_address{"None"};
};

class Player : public Person {
public:
    Player() = default;
    explicit Player(std::string_view game) : m_game{game} {}

    void play() const {
        std::cout << "Playing " << m_game << " as " << m_full_name << "\n";
        // m_age 也可以访问(protected)
        // m_address 不行(private)
    }

private:
    std::string m_game{"None"};
};

语法 class Player : public Person 读作「Player 公开继承自 Person」。创建 Player 对象时,编译器会先在里面构造一个完整的 Person 子对象,再叠加 Player 自己的成员:

Player 对象
├─ Person 子对象
│   ├─ m_full_name   (public)
│   ├─ m_age         (protected)
│   └─ m_address     (private)
└─ m_game           (Player 自己的 private 成员)

用调试器展开一个 Player 对象,会看到 Person 这一层,下面挂着它的三个成员,再往下才是 m_game。这就是「派生类对象包含基类子对象」的直接证据。

访问规则:private 成员对派生类也关闭

  • m_full_name 是 public:派生类和外部都能用。
  • m_age 是 protected:派生类内部能用,外部不能用。
  • m_address 是 private:派生类内部也不能用。它属于 Person 的私有实现,即使在 Player 对象内部,Player 的成员函数也无权直接读写。
void Player::change() {
    m_full_name = "Jon Snow";  // 可以
    m_age = 30;                // 可以(protected)
    // m_address = "...";      // 编译错误:private to Person
}

如果派生类确实需要读写基类的私有数据,只能走基类提供的 public 接口(getter/setter),或者由基类作者把成员改成 protected。

这解释了一个常被误解的说法:「public 继承保留了成员的访问级别」——对 public 和 protected 成员成立,对 private 成员不成立。private 成员永远属于定义它的那个类。

三种继承方式

继承时在冒号后写的关键字,叫基类访问说明符,它决定基类成员在派生类中的新级别:

基类中的成员public 继承protected 继承private 继承
publicpublicprotectedprivate
protectedprotectedprotectedprivate
private不可访问不可访问不可访问

三条要点:

  1. 只能收紧,不能放宽。 继承说明符给出的级别是上限:public 继承保持原样,protected 继承把 public 降为 protected,private 继承把 public 和 protected 都降为 private。
  2. private 成员在任何继承方式下都不可访问。 表格最后一行是关键,也是初学者最容易忽略的一行。
  3. 继承方式影响的是「再往下派生」。 private 继承得到的成员对当前类是私有的,所以当前类的派生类也拿不到。

private 继承为什么叫「自私」

课程用了一个很贴切的比喻。设:

class Engineer : private Person { /* ... */ };
class CivilEngineer : public Engineer { /* ... */ };

Engineer 私有继承 Person 后,Person 的 public 成员(m_full_name、get_full_name)在 Engineer 里全部变成 private。于是 CivilEngineer 即使公开继承 Engineer,也拿不到这些成员——它们在 Engineer 那里就被截断了。Engineer 等于说:「我从 Person 拿到的一切都归我,不往下传。」

CivilEngineer 对象内部依然有完整的 Person 子对象(调试器里能看到),只是这些成员的访问权限被上层设计封死了。数据还在,接口没了。

一个完整例子

课程用同一套 Person/Player 代码演示三种继承:

class Player  : public    Person { /* 内部可用 public、protected;外部可用 public */ };
class Nurse   : protected Person { /* 内部可用 public、protected;外部都不可用 */ };
class Engineer: private   Person { /* 内部可用 public、protected;外部都不可用,且不可再向下传递 */ };

在 main 中:

Player p;
p.m_full_name = "Samwell";   // 可以:public 继承保留 public
// p.m_age = 30;             // 错误:protected,外部不可见
// p.m_address = "Castle";   // 错误:private to Person

Nurse n;
// n.m_full_name = "Davy";   // 错误:protected 继承把 public 降为 protected

Engineer e;
// e.m_full_name = "Olivier"; // 错误:private 继承把它降为 private

而 Engineer 自己的成员函数里,m_full_name 和 m_age 都能用,m_address 仍然不能用。

本章结论

  • struct 与 class 的唯一区别是默认访问级别;只有公开数据时优先用 struct。
  • 对象大小约等于成员变量大小之和;函数不计入,指针不计所指内容,对齐会引入填充。
  • 继承让派生类对象内部包含一个基类子对象,构造、访问、销毁都按这个结构进行。
  • public 继承不改变 public/protected 成员的级别;private 成员对任何派生类都不可见。
  • protected 继承把 public 降为 protected;private 继承把 public 和 protected 都降为 private。
  • private 继承会截断向下的可见性,因此被称作「自私」的继承。

理解检验

1. struct 和 class 的唯一区别是什么?什么时候应该优先用 struct?

参考答案:唯一区别是未写访问说明符时的默认级别:class 的成员默认 private,struct 默认 public。二者都支持构造、析构、成员函数、继承和访问说明符。当类型只是把若干数据捆在一起、不维护不变量、不提供行为时(如 Point{x, y}),用 struct 更直接:读者一眼看出它是数据聚合。一旦需要控制写入或维护约束(半径非负、状态合法),就该用 class 加 private 成员。

2. 为什么 sizeof(std::string) 与字符串内容长短无关?对象大小里到底算了什么?

参考答案:std::string 在典型实现里保存的是一个指向字符缓冲区的指针、长度和容量(还可能包含小字符串优化的内联缓冲区),对象本身只占固定大小(常见为 32 字节)。字符串内容分配在堆上,对象里只存地址与元数据,所以 "hi" 和一千个字符的字符串占用同样大小的 string 对象。把这一点推广:对象大小只统计成员变量本身,指针只计指针的宽度,不计它指向的内存;成员函数属于类,不计入任何对象。

3. 派生类能否直接访问基类的 private 成员?为什么这个限制在编译期就能发现?

参考答案:不能。private 成员的访问权限绑定在「定义它的类的作用域」上;派生类的作用域虽然在基类作用域之内查找名字,但访问控制检查会拒绝 private 成员,编译器直接报 is private within this context。这个检查完全基于声明与作用域,不需要运行程序,所以编译期就能发现。派生类若确实需要这些数据,只能调用基类的 public 接口,或者请基类作者把成员改成 protected。

4. 写出 public 继承下,基类三种成员在派生类中的可见性,并说明 private 成员为什么特殊。

参考答案:public 成员在派生类中仍是 public;protected 成员仍是 protected;private 成员不可访问。前两者是「继承下来的成员」,只是级别不变;private 成员虽然物理上存在于派生对象的基类子对象里,但访问控制把它永久绑定在基类作用域内,任何派生类都不能直接读写。所以 public 继承「保持可见性」的说法只对 public 和 protected 成立。

5. private 继承为什么被形容为「自私」?CivilEngineer 明明包含完整的 Person 子对象,为什么访问不到 Person 的 public 成员?

参考答案:Engineer : private Person 把从 Person 继承来的 public 与 protected 成员统统降为 Engineer 的 private 成员。于是再派生 CivilEngineer : public Engineer 时,这些成员已经是 Engineer 的私有成员,按规则任何派生类都不能访问,也无法「恢复」它们的可见性。数据确实还在——CivilEngineer 对象内部有 Engineer 子对象,Engineer 内部又有 Person 子对象——但接口被上层截断了。Engineer 拿走了基类的一切,却不往下一代传,因此称为自私。

6. protected 与 private 的区别是什么?各举一个适用场景。

参考答案:protected 成员对「本类及其派生类的成员函数」可见,对外部不可见;private 成员只对本类可见,派生类也不行。需要让派生类直接读写、又不希望外部碰的状态(例如基类里的 m_age,派生类构造时要设置它)适合 protected。纯粹属于本类实现、派生类不应依赖的细节(例如内部缓存、地址字段)应保持 private,避免派生类耦合到实现细节。

与前后章的关系

本章建立了继承的基本语法与访问规则,但留下一个明显的问题:private 继承把成员全部封死,派生类再也拿不到;如果确实想让某些成员向下传递怎么办?下一章先用 using 声明把被遮蔽的成员「复活」,再依次解决构造顺序、向基类构造传参、拷贝构造、析构顺序与同名成员遮蔽。

This is chapter 19 of the 31-hour course, covering 22:33:31–24:26:26. It closes two loose ends about classes — struct versus class, and object size — and then builds inheritance from the ground up: how a base class is embedded in a derived object, and how the three access specifiers change visibility.

What this chapter is about

Three questions, in order:

  1. What actually separates struct from class? Only the default access level.
  2. How large is an object in memory? The sum of its member variables plus alignment padding; functions do not count.
  3. How does inheritance work? A derived object contains a base-class subobject, stacked like nested boxes.

The third point is the spine of the chapter. Once you accept that "the base part is a real subobject inside the derived object", everything that follows — access rules, construction order, object slicing — has a physical explanation.

Timeline

TimeTopic
22:33struct versus class: different defaults, otherwise identical
22:35Modelling a Point with a struct: a lightweight data type
22:42Object size: sum of members; pointers do not count what they point to
22:44Alignment padding makes objects slightly larger than the sum
22:52Why inheritance: person → engineer → civil engineer
22:56First inheritance: class Player : public Person
23:00Derived classes reach public base members only; private stays hidden
23:13Using the debugger to see the embedded base subobject
23:20protected: visible to derived classes, invisible outside
23:31Three inheritance forms: the public / protected / private mapping
23:36Full demo: Person, Player, Nurse, Engineer, CivilEngineer

struct versus class: one default

class Dog {
    std::string m_name;   // private by default
};

struct Cat {
    std::string m_name;   // public by default
};

Beyond that, the two are interchangeable: both support constructors, member functions, inheritance and access specifiers. The only difference is the access level applied when none is written.

Dog dog1;
// dog1.m_name = "Fluffy";   // error: private

Cat cat1;
cat1.m_name = "Johnny";      // fine: struct members are public

Write public: or private: explicitly and the behaviour is identical.

When to prefer struct

The rule of thumb from the lecture: use a struct when the type is only public data and carries no member functions.

struct Point {
    double x;
    double y;
};

void print_point(const Point& p) {
    std::cout << "Point[x:" << p.x << ", y:" << p.y << "]\n";
}

Point maintains no invariant and has no behaviour; exposing its data is the whole point. Using struct tells a reader immediately: this is a data aggregate, not an encapsulated object. Once you need to police writes or maintain an invariant (a radius that cannot be negative), switch to class with private members and setters.

Object size: members plus alignment

class Wrapper {
    int m_a;
    int m_b;
    std::string m_name;
};

sizeof(Wrapper) is not "two ints plus the length of the string". Typically:

  • two ints: 4+4=84+4=8 bytes;
  • one std::string: 32 bytes in common implementations, independent of content;
  • roughly 40 bytes in total, depending on the standard library.

Three consequences:

  1. Functions do not count. Member functions belong to the class, not to an individual object. A million objects share one copy of the code; there is no reason to charge each object for it.
  2. A pointer counts only itself. An int* member stores an 8-byte address; the size of the array it points to is irrelevant. std::string is internally a pointer plus length and capacity (plus a small-string buffer), so "hi" and a thousand-character string occupy the same sizeof.
  3. Alignment introduces gaps. Most compilers require a 4-byte member to start at an address that is a multiple of 4. After a char (1 byte) and before an int (4 bytes), padding fills the gap:
struct Mixed {
    char c;    // 1 byte
    int  i;    // 4 bytes, needs 4-byte alignment
};
// sizeof(Mixed) is typically 8, not 5

This is also why reordering members can shrink an object: put the most strictly aligned members first and the padding shrinks.

Inheritance: building a class on top of a class

Consider a hierarchy of people: Person holds what all people share; Engineer is "a Person who engineers"; CivilEngineer is "an Engineer who specialises in civil works". Each level specialises the one above — exactly what inheritance expresses.

class Person {
public:
    Person() = default;
    Person(std::string_view full_name, int age, std::string_view address)
        : m_full_name{full_name}, m_age{age}, m_address{address} {}

    std::string get_full_name() const { return m_full_name; }
    int get_age() const { return m_age; }
    std::string get_address() const { return m_address; }

public:
    std::string m_full_name{"None"};

protected:
    int m_age{0};

private:
    std::string m_address{"None"};
};

class Player : public Person {
public:
    Player() = default;
    explicit Player(std::string_view game) : m_game{game} {}

    void play() const {
        std::cout << "Playing " << m_game << " as " << m_full_name << "\n";
        // m_age is reachable (protected)
        // m_address is not (private)
    }

private:
    std::string m_game{"None"};
};

class Player : public Person reads as "Player publicly inherits from Person". Creating a Player first builds a complete Person subobject inside it, then adds Player's own members:

Player object
├─ Person subobject
│   ├─ m_full_name   (public)
│   ├─ m_age         (protected)
│   └─ m_address     (private)
└─ m_game           (Player's own private member)

Expand a Player in the debugger and the Person layer appears, with its three members, and m_game below it. That is direct evidence for "a derived object embeds its base subobject".

Access rules: private stays private even for derived classes

  • m_full_name is public: usable inside and outside.
  • m_age is protected: usable inside derived classes, not outside.
  • m_address is private: not usable even inside Player. It belongs to Person's private implementation; Player's member functions cannot read or write it directly.
void Player::change() {
    m_full_name = "Jon Snow";  // fine
    m_age = 30;                // fine (protected)
    // m_address = "...";      // error: private to Person
}

If a derived class truly needs that data, it must go through a public base interface (getter/setter), or the base author must relax the member to protected.

This corrects a common misreading: "public inheritance preserves member access" holds for public and protected members, but not for private ones. Private members belong to the class that declared them, permanently.

Three forms of inheritance

The keyword after the colon is the base-class access specifier. It decides the level base members take in the derived class:

Base memberpublic inheritanceprotected inheritanceprivate inheritance
publicpublicprotectedprivate
protectedprotectedprotectedprivate
privateinaccessibleinaccessibleinaccessible

Three points:

  1. Inheritance can only tighten, never loosen. public keeps levels as they are; protected lowers public to protected; private lowers both public and protected to private.
  2. Private members are inaccessible under every form. The last row is the one beginners miss.
  3. The specifier matters for the next derivation. private inheritance makes everything private to the current class, so its own derived classes receive nothing usable.

Why private inheritance is "selfish"

class Engineer : private Person { /* ... */ };
class CivilEngineer : public Engineer { /* ... */ };

After Engineer privately inherits Person, Person's public members (m_full_name, get_full_name) become private members of Engineer. So CivilEngineer, even though it publicly inherits Engineer, cannot reach them — they were cut off at the Engineer level. Engineer is effectively saying: "everything I take from Person is mine, and I pass none of it down."

A CivilEngineer object still contains a complete Person subobject — the debugger shows it — but the interface has been sealed by a design decision upstream. The data is present; the access is gone.

A complete demonstration

The lecture runs the same Person/Player code through all three forms:

class Player  : public    Person { /* inside: public + protected; outside: public */ };
class Nurse   : protected Person { /* inside: public + protected; outside: nothing */ };
class Engineer: private   Person { /* inside: public + protected; outside: nothing, and nothing downstream */ };

In main:

Player p;
p.m_full_name = "Samwell";   // fine: public inheritance preserves public
// p.m_age = 30;             // error: protected, invisible outside
// p.m_address = "Castle";   // error: private to Person

Nurse n;
// n.m_full_name = "Davy";   // error: protected inheritance demoted public to protected

Engineer e;
// e.m_full_name = "Olivier"; // error: private inheritance demoted it to private

Inside Engineer's own member functions, m_full_name and m_age are both usable; m_address still is not.

Chapter summary

  • struct and class differ only in the default access level; prefer struct for plain public data.
  • Object size is roughly the sum of member variables; functions do not count, pointers count only themselves, and alignment adds padding.
  • Inheritance embeds a base subobject inside every derived object; construction, access and destruction all follow that structure.
  • public inheritance preserves public and protected levels; private members are invisible to every derived class.
  • protected inheritance demotes public to protected; private inheritance demotes public and protected to private.
  • private inheritance cuts off visibility downstream, which is why it is called selfish.

Comprehension check

1. What is the only difference between struct and class, and when should a struct be preferred?

Reference answer: The only difference is the default access level when none is written: class members are private, struct members are public. Both support constructors, destructors, member functions, inheritance and access specifiers. Prefer struct when the type merely bundles data, maintains no invariant and offers no behaviour — Point{x, y} for instance — because it tells the reader at a glance that this is an aggregate. Once writes must be policed or an invariant enforced (a non-negative radius, a valid state), switch to class with private members.

2. Why is sizeof(std::string) independent of the text it holds? What exactly does object size count?

Reference answer: A typical std::string stores a pointer to a heap buffer, a length and a capacity, possibly plus an inline small-string buffer. The object therefore has a fixed size (commonly 32 bytes); the characters themselves live on the heap and the object only holds the address and metadata. Hence "hi" and a thousand-character string occupy the same sizeof. Generalising: object size counts member variables only, a pointer counts by its own width rather than the size of its target, and member functions belong to the class and are charged to no object.

3. Can a derived class access a base class's private members directly? Why is this caught at compile time?

Reference answer: No. Access to a private member is bound to the scope of the class that declared it. A derived class does look names up in the base class scope, but the access check rejects private members, and the compiler reports is private within this context. The check is purely syntactic, based on declarations and scope, so it happens at compile time without running the program. If the data is genuinely needed, the derived class must use a public base interface, or the base author must relax the member to protected.

4. State the visibility of each base member kind under public inheritance, and explain why private members are special.

Reference answer: public members stay public; protected members stay protected; private members are inaccessible. The first two are inherited members whose level is unchanged. A private member physically exists inside the base subobject of the derived object, but access control binds it permanently to the base class scope, so no derived class may read or write it directly. That is why "public inheritance preserves visibility" is only true for public and protected members.

5. Why is private inheritance described as selfish? CivilEngineer contains a full Person subobject, so why can it not touch Person's public members?

Reference answer: Engineer : private Person demotes every inherited public and protected member to a private member of Engineer. When CivilEngineer : public Engineer is then derived, those members are already private to Engineer, and the rules forbid any derived class from accessing them; visibility cannot be restored downstream. The data is still there — a CivilEngineer contains an Engineer subobject, which contains a Person subobject — but the interface was severed upstream. Engineer keeps everything it took from Person and passes none of it on, hence "selfish".

6. How do protected and private differ, and when is each appropriate?

Reference answer: protected members are visible to the class itself and to member functions of its derived classes, but not to outsiders. private members are visible only to the class itself. Use protected when derived classes genuinely need to read or write state that outsiders must not touch — for example a base's m_age, which a derived constructor sets. Keep private for details that are purely internal and that derived classes should not depend on, such as caches or bookkeeping fields, to avoid coupling derived classes to implementation.

Relation to neighbouring chapters

This chapter establishes the syntax and access rules of inheritance, but leaves an obvious problem: private inheritance seals everything off, so what if some members should be passed downstream after all? The next chapter first revives hidden members with using declarations, then works through construction order, forwarding arguments to base constructors, copy construction, destruction order and name hiding.

来源