Featured image of post C++|2.类与对象

C++|2.类与对象

从类的基本构造、特殊函数(构造、拷贝、析构),到静态成员、常量成员,再深入到继承体系与解决菱形继承问题的虚基类

写在前面
期末复习ing,于是写下这篇博客,帮助自己回忆最核心的C++语法。

如果你是读者,我假设你已经掌握了至少一门面向对象的语言。

引言:设计一个健壮的"组件蓝图"

想象你是C++的设计者,你想要为程序员提供一套工具,让他们能够设计出健壮、可扩展的"组件蓝图"。

这个过程会遇到哪些问题?又该如何解决?

让我们跟随这条思路,重新审视C++面向对象的每一个概念。

第一步:定义蓝图 (class)

设计思考: 我们首先需要一个基本的蓝图来描述"事物"的属性和行为。

在C++中,一个典型的类长这样:

1
2
3
4
5
6
7
8
class A{
private:
    int a;          // 内部数据,需要保护
public:
    A();            // 如何创建对象
    A(int a);       // 带参数的创建方式
    int func();     // 对外提供的功能
};

关键设计理念:

  • private public protected 是访问控制符,用来划分哪些是希望外部使用的接口,哪些是需要保护的内部细节。
  • 这样我们就像画图纸时,决定哪些细节是"公开展览",哪些只在工厂内部流转。

一般来说,我们会在头文件(.h .hpp)里声明接口,再在具体文件(.cpp)内实现。

要实例化这个类成对象,就像"根据图纸造车":

1
2
A a1;    // 调用默认构造函数,像买一辆标准配置的车
A a2(1); // 调用带参数的构造函数,像定制一辆有特殊配置的车

当然,你也可能看到诸如:

1
2
Player p1 = Player();
auto p1 = Player(); //C++ 11 新特性

这样的写法涉及到了拷贝构造函数,我们稍后再聊。

类中的特殊函数

构造函数

构造函数就像"工厂流水线"——每辆车(对象)下线时,必须经过一套标准流程(构造函数),把轮胎、发动机、车标都装好。

在 C++ 中, 一个类的初始化函数叫构造函数,它的标准写法是:

1
类名(参数表) : 初始化列表 { 函数体 }

构造函数的执行顺序:

  1. 初始化列表:就像你在装配车时,先把所有零件按顺序装好(初始化列表)
  2. 构造函数体:再做最后的检测和设置(函数体)

这个顺序很重要,因为:

  • const 成员和引用成员必须在初始化列表中初始化
  • 初始化列表比函数体内的赋值更高效

拷贝构造函数

拷贝构造函数就像"复印机"——你要复印一份文件(对象),但有些内容(比如身份证照片)不能直接复印,需要特殊处理(深拷贝)。

1
2
3
类名(const 类名& other){
    ptr = new int(*other.ptr);  //示例:复印时也要重新拍一张照片
} //复制自己的拷贝构造函数

回到我们前面说到的auto p = Player();,它先调用直接构造函数生成一个临时对象,该对象作为参数传入p的拷贝构造函数。

现代 C++ 编译器有"拷贝省略"优化,Player p1 = Player(); 最终的效果和 Player p1; 几乎一样。

析构函数

1
2
~类名(){
}

在对象生命周期结束时自动调用,用于释放资源,例如 delete 一个在构造函数里 new 出来的指针。

第三步:赋予"类别"属性 (静态成员)

设计思考: 有些属性不应属于某个具体对象,而应属于"类别"本身,比如"生产线上总共制造了多少个产品"。这就是静态成员的用武之地。

静态成员被所有对象共享,生存期从程序开始到结束。它需要类内声明,类外初始化。

比如:

1
2
3
4
5
6
7
8
9
class Car {
public:
    static int total; // 所有车共用的"总账本"
    Car() { ++total; }
};
int Car::total = 0;

Car a, b, c;
std::cout << Car::total << std::endl; // 输出3

静态数据成员:共享的类属性

在类内声明格式:

1
static 数据类型 变量名;

类外初始化格式:

1
数据类型 类名::变量名 = ;

这为整个类提供了一个共享的数据平台。

静态成员函数:为操作静态数据而生

设计思考: 静态数据成员不属于某一个特定的对象,那我在没有创建对象时想要访问静态数据怎么办?所以就有了这个为了操作静态数据成员而生的函数。

1
static 函数名();

在类内按常调用,在类外使用类名::函数名对象.函数名调用

第四步:建立"契约" (const 的世界)

设计思考: const 就像"封条"——有些车一旦下线(const对象),某些零件(const成员)就不能再动了。只有承诺"绝不改动"的维修工(const成员函数)才能碰它。

常对象:只读的对象实例

顾名思义,就是一个"只读"的对象。一旦它被创建和初始化后,你就不能再以任何方式修改它的状态。

这是一种非常有用的机制,可以增强程序的健壮性,防止对象被意外篡改。

我们这样定义常对象:

1
2
类型 const 对象名;
const 类型 对象名;

核心规则:

  1. 数据成员不可变 一旦一个对象被声明为 const,它的所有数据成员都不能再被修改。任何直接尝试赋值的操作都会导致编译错误。

  2. 只能调用常成员函数 这是最关键的一条规则。为了保证数据不被修改,const 对象只能调用那些被 const 关键字修饰的成员函数(即常成员函数)。

1
2
void func() const; // <-- 可以被常对象调用(因为它承诺不修改数据)
void func(); // <-- 不可以被常对象调用(因为它没有做出承诺,编译器会假定它可能会修改数据)

常数据成员

常数据成员是类中一个特殊的变量,一旦在对象创建时被初始化,它的值在其整个生命周期中都不能被修改。它代表了对象的一个内在的、不变的属性。

核心规则:

  1. 必须在构造函数的初始化列表中进行初始化。 用来表示一个对象自诞生起就不应改变的属性,比如一个人的身份证号、订单的唯一ID等,这能从语法层面保证数据的绝对安全。

定义:

1
const 数据类型 变量名;

常成员函数

这是我们经常使用的一个语法,用来向编译器做出“我不会修改对象内部任何数据”承诺。

它为常对象提供一个安全的、可供调用的接口,也提高了正常对象的安全性。

1
返回值 函数名(参数列表) const;

核心规则:

  1. 内部数据只读 在常成员函数内部,所有的非静态数据成员都被视为 const,你不能对它们进行赋值。

  2. 只能调用其他 const 函数 在一个 const 成员函数内部,你只能调用其他的 const 成员函数,不能调用非 const 的成员函数.

  3. 常对象可以调用 这是它最重要的作用之一。const 对象只能调用 const 成员函数。

继承

生活化理解与专业说明

继承就像“家族谱”——你是你爸妈的孩子,你继承了他们的姓氏、长相(成员变量),也可以有自己的特长(新成员)。

比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Human {
public:
    std::string name;
    void eat() { std::cout << name << " 吃饭" << std::endl; }
};
class Student : public Human {
public:
    void study() { std::cout << name << " 学习" << std::endl; }
};
Student s;
s.name = "小明";
s.eat();   // 小明 吃饭
s.study(); // 小明 学习

访问控制就像“家规”——有的东西只能家里人知道(protected),有的可以公开(public),有的只有自己能用(private)。

构造顺序就像“家族聚会”——长辈(基类)总是先到,晚辈(派生类)后到,最后大家一起合影(对象构造完毕)。


专业知识补充

继承的实现原理

  1. 派生类会吸收基类的所有成员(除了构造/析构函数和私有成员)。
  2. 访问限制由继承方式(public/protected/private)和成员自身的访问控制符共同决定。
  3. 如果派生类和基类有同名成员,派生类会覆盖基类的同名成员(无论参数表和类型是否相同)。
  4. 派生类可以添加自己的新成员。

继承方式对成员可见性的影响

继承方式\原本访问控制publicprotectedprivate
publicpublicprotected派生类不可访问
protectedprotectedprotected派生类不可访问
privateprivateprivate派生类不可访问

构造与析构顺序

  1. (虚基类,如果有的话)
  2. 按照继承声明的顺序调用非虚基类的构造函数
  3. 按照类中声明的顺序调用成员对象的构造函数
  4. 调用自身的构造函数 析构顺序与构造顺序相反。

虚基类

虚基类就像“家族群聊”——如果你既是你爸的孩子,又是你妈的孩子(菱形继承),你只需要进一个群(只保留一份基类),大家都能联系你,不会重复收到消息。

比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Grandpa {
public:
    std::string familyName;
};
class Father : virtual public Grandpa {};
class Mother : virtual public Grandpa {};
class Child : public Father, public Mother {
public:
    Child() { familyName = "王"; }
};
Child c;
std::cout << c.familyName << std::endl; // 只有一份 familyName

构造顺序就像“群主先发言”——群主(虚基类)总是第一个发言,然后才轮到其他成员。


专业知识补充

虚基类的作用

为了解决菱形继承造成的多个相同基对象数据不一致问题。

虚基类的使用

1
class C: virtual public B;

这样子就只保留一份基对象了。

虚基类的构造顺序

  1. 虚基类先于非虚基类构造:无论在继承层次中的位置如何,所有虚基类都优先构造。
  2. 虚基类只构造一次:即使被多个派生类继承,虚基类的构造函数只调用一次(只有最后一次生效)。
  3. 最终派生类负责虚基类的构造:中间类对虚基类构造函数的初始化参数会被忽略,但是所有直接或间接拥有虚基类的类都必须在初始化列表中初始化虚基类。
  4. 完整构造顺序
    • 虚基类(按照声明顺序)
    • 非虚基类(按照声明顺序)
    • 成员变量(按照声明顺序)
    • 派生类构造函数体

总结

好了,让我们暂停一下,从 C++ 设计者的视角,把今天聊过的所有内容串成一个完整的故事。

我很喜欢这样思考,因为知道前因后果后很多事情似乎就会变得理所当然。

这个过程,就像我们在设计一个健壮、可扩展的“组件蓝图”。

  1. 起点:定义蓝图 (class) 我们首先需要一个最基本的蓝图——class。我们用它来描绘一个“事物”的核心属性和行为,并通过 public 和 private 划分出哪些是希望外部使用的接口,哪些是需要保护的内部细节。

  2. 思考生命周期:构造、析构与拷贝 蓝图有了,但“生命”如何开始、结束和复制?于是构造函数应运而生,它定义了对象如何被正确地“制造”出来,特别是通过初始化列表,我们能精确控制每个零件的初始状态。而析构函数则确保了对象在消亡时,能优雅地归还所有占用的资源。 更进一步,我们考虑到对象会被复制,为了避免浅拷贝带来的麻烦(比如两个指针指向同一块内存),我们设计了拷贝构造函数,它让我们能深度掌控复制行为,确保每个副本都是独立且完整的。

  3. 赋予“类别”属性:静态成员 在设计中我们发现,有些属性不应属于某个具体对象,而应属于“类别”本身,比如“生产线上总共制造了多少个产品”。这就是静态成员的用武之地,它为整个类提供了一个共享的数据和操作平台。

  4. 建立“契约”:const 的世界 为了让蓝图更安全、意图更明确,我们引入了 const 契约。用常数据成员固化对象不变的属性,用常成员函数承诺某些操作是“只读”的,最终,我们可以放心地将一个常对象交给客户,因为它在语法层面就被保护了起来,无法被篡改。

  5. 扩展与复用:继承 我们不希望每次都从零开始设计。当遇到“is-a”(是一个)的关系时,继承就登场了。它允许我们基于一个已有的蓝图,去创造一个更特化的新蓝图,既复用了代码,也构建了清晰的层级关系。我们还通过不同的继承方式,精细地控制着权限的传递。

  6. 解决复杂性:虚基类 当继承关系变得复杂,出现了“菱形继承”时,我们发现简单的继承会导致数据冗余和二义性。为了解决这个设计难题,我们引入了终极武器——虚基类。它确保在复杂的继承树中,共享的基类永远只有一个实例,让我们的设计回归清晰和高效。

本文采用 CC BY 4.0 许可协议,转载请注明出处。
最后更新于 Jul 03, 2025 16:28 +0800
使用 Hugo 构建
主题 StackJimmy 设计