跳转至

3.1 类

在C++中,我们使用类定义自己的数据类型。

类的基本思想是 数据抽象(data abstraction)封装(encapsulation) 。数据抽象是一种依赖于 接口(interface)实现(implementation) 分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。

封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,类的用户只能使用接口而无法访问实现部分。

类要想实现数据抽象与封装,需要定义一个 抽象数据类型(Abstract Data Type) 。在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员只需要抽象地思考类型做了什么,而无需了解类型的工作细节。

不同的编程角色

程序员们常把运行其程序的人称作用户(user)。类似的,类的设计者也是为其用户设计并实现一个类的人;显然,类的用户是程序员,而非应用程序的最终使用者

当我们提及“用户”一词时,不同的语境决定了不同的含义。如果我们说用户代码或者 Sales_data 类的用户,指的是使用类的程序员;如果我们说书店应用程序的用户,则意指运行该应用程序的书店经理。

C++程序员们无须刻意区分应用程序的用户以及类的用户。

在一些简单的应用程序中,类的用户和类的设计者常常是同一个人。尽管如此,还是最好把角色区分开来。当我们设计类的接口时,应该考虑如何才能使得类易于使用;而当我们使用类时,不应该顾及类的实现机理。

要想开发一款成功的应用程序,其作者必须充分了解并实现用户的需求。同样,优秀的类设计者也应该密切关注那些有可能使用该类的程序员的需求。作为一个设计良好的类,既要有直观且易于使用的接口,也必须具备高效的实现过程。

3.0.2 自定义数据结构

从最基本的层面理解,数据结构是把一组相关的数据元素组织起来然后使用它们的策略与方法。

一个简单的不带有任何运算功能的数据结构:

struct Sales_data {
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;

};

这个类从关键字struct开始,紧跟类名和类体,类体由花括号包围形成了一个新的作用域。类内部定义的名字必须唯一,但是可以与类外部定义的名字重复。

类体定义了类体的 成员,在这里只有 数据成员(data member)。类的数据成员定义了类的对象的具体内容——对象能够存储哪些信息,每个对象有自己的一份数据成员拷贝。修改一个对象的数据成员,不会影响其他到其他对象。

定义数据对象和定义普通变量一样。

C++11标准规定,可以为数据成员提供一个 类内初始值(in-class initializer)

类通常被定义在同文件中,而且类所在的头文件的名字应与类的名字一样。

Sales_data.h

#ifndef SALES_DATA_H
#define SALES_DATA_H

#include <string> // 包含string头文件
struct Sales_data {
    std::string bookNo; // 使用std::string
    unsigned units_sold = 0;
    double revenue = 0.0;
};
#endif // SALES_DATA_H

main.cpp

#include<bits/stdc++.h>
#include "Sales_data.h"
using namespace std;

int main(){
    Sales_data data1, data2;
    
    double price;
    cin >> data1.bookNo >> data1.units_sold >> price;
    data1.revenue = data1.units_sold * price;
    cin >> data2.bookNo >> data2.units_sold >> price;
    data2.revenue = data2.units_sold * price;
    
    if (data1.bookNo == data2.bookNo) {
        unsigned totalCnt = data1.units_sold + data2.units_sold;
        double totalRevenue = data1.revenue + data2.revenue;
        // 输出:ISBN、总销售量、总销售额、平均价格
        cout << data1.bookNo << " " << totalCnt << " " << totalRevenue << " ";
        if (totalCnt != 0)
            cout << totalRevenue/totalCnt << endl;
        else
            cout << "(no sales)" << endl;
        return 0;  // 标示成功
    } else {
        // 两笔交易的ISBN不一样
        cerr << "Data must refer to the same ISBN" << endl;
        return -1;  // 标示失败
    }
    return 0;
}

预处理器概述

确保头文件多次包含仍能安全工作的常用技术是 预处理器 (preprocessor),它由 C++ 语言从 C 语言继承而来。预处理器是在编译之前执行的一段程序,可以部分地改变我们所写的程序。之前已经用到了一项预处理功能 #include,当预处理器看到 #include 标记时就会用指定的头文件的内容代替 #include

C++程序还会用到的一项预处理功能是 头文件保护符 (header guard),头文件保护符依赖于预处理变量。预处理变量有两种状态:已定义和未定义。

  • #define 指令把一个名字设定为预处理变量,
  • 另外两个指令则分别检查某个指定的预处理变量是否已经定义:
  • #ifdef 当且仅当变量已定义时为真,
  • #ifndef 当且仅当变量未定义时为真。

一旦检查结果为真,则执行后续操作直至遇到 #endif 指令为止。

使用这些功能就能有效地防止重复包含的发生:

#ifndef SALES_DATA_H
#define SALES_DATA_H
#include <string>
struct Sales_data {
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};
#endif

第一次包含 Sales_data.h 时,#ifndef 的检查结果为真,预处理器将顺序执行后面的操作直至遇到 #endif 为止。此时,预处理变量 SALES_DATA_H 的值将变为已定义,而且 Sales_data.h 也会被拷贝到我们的程序中来。后面如果再一次包含 Sales_data.h,则 #ifndef 的检查结果将为假,编译器将忽略 #ifndef#endif 之间的部分。

Warning

预处理变量无视 C++ 语言中关于作用域的规则。

整个程序中的预处理变量包括头文件保护符必须唯一,通常的做法是基于头文件中类的名字来构建保护符的名字,以确保其唯一性。为了避免与程序中的其他实体发生名字冲突,一般把预处理变量的名字全部大写。

3.1.1 定义抽象数据类型

类体定义了类体的 成员:

  • 数据成员(data member)
  • 成员函数(member function)

定义改进的Sales_data类

#ifndef SALES_DATA_H
#define SALES_DATA_H

#include <string> // 包含string头文件
struct Sales_data {
    // 新成员:关于 Sales_data 对象的操作
    std::string isbn() const { return bookNo; }
    Sales_data& combine(const Sales_data&);
    double avg_price() const;
    // 数据成员
    std::string bookNo; // 使用std::string
    unsigned units_sold = 0;
    double revenue = 0.0;
};
//非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
#endif // SALES_DATA_H

定义成员函数

  • 所有成员都必须在类的内部 声明
  • 但成员函数体可以 定义 在类内(上述的isbn())也可以 定义 在类外 (combine();avg_price();

引入this

关于isbn函数,它是如何获得bookNo成员所依赖的对象的呢?

其实成员函数通过一个名为 this 的额外的隐式参数来访问调用它的那个对象。当我们调用成员函数时,用请求该函数的对象地址初始化 this。

对于 total.isbn()而言,编译器把total的地址传递给 isbn的隐式形参this—— Sales_date::isbn(&total)

在成员函数内部,我们可以直接使用调用【“调用该函数的对象”的成员】,而无需通过成员访问运算符来做到这一点,因为this所指的正是这个对象。

引入 const 成员函数

isbn成员函数中,const的作用是修改隐式this指针的类型,相应的成员函数称作 常量成员函数(const member function)

默认情况下,this的类型是 指向类类型非常量版本的常量指针 。所以不能把this绑定到一个常量对象上,也不能在一个常量对象上调用普通的成员函数(非const成员函数)。

成员函数的调用与this的隐式转换

这个问题需要从 C++的成员函数底层实现机制const语义的强制性 来理解。让我们分步骤分析:


1-成员函数的底层实现

在 C++ 中,成员函数(包括非 const 成员函数)实际上会被编译器“翻译”成一个普通函数,并隐式添加一个名为 this 的参数。例如:

class MyClass {
public:
    void func(int x) { /* ... */ }  // 非 const 成员函数
};
会被编译器处理为类似:
void func(MyClass* this, int x) { /* ... */ }  // 隐式添加 `this` 参数

const 成员函数this 参数类型是 const MyClass*

class MyClass {
public:
    void const_func(int x) const { /* ... */ }  // const 成员函数
};
会被处理为:
void const_func(const MyClass* this, int x) { /* ... */ }  // `this` 是常量指针


2-调用成员函数时的隐式转换

当通过对象调用成员函数时,编译器会隐式将对象的地址传递给 this 参数。例如:

MyClass obj;
obj.func(10);  // 编译器实际调用 func(&obj, 10)

关键点

  • 非 const 成员函数this 参数类型是 MyClass*(非常量指针)。
  • const 成员函数this 参数类型是 const MyClass*(常量指针)。

3-常量对象调用非 const 成员函数时的矛盾

假设有一个常量对象:

const MyClass const_obj;
const_obj.func(10);  // 尝试调用非 const 成员函数

此时,编译器需要将 const_obj 的地址传递给 functhis 参数。但:

  • const_obj 的类型是 const MyClass,其地址类型为 const MyClass*
  • functhis 参数类型是 MyClass*(非 const)。

为了匹配参数类型,编译器需要将 const MyClass* 隐式转换为 MyClass*(即“去掉 const 性”)。


4-为什么不允许这种隐式转换?

C++ 的 常量性(const-correctness) 规则强制要求:不能隐式去除 const
这种限制是类型安全的基石,目的是防止以下危险行为:

const MyClass const_obj;
const_obj.modify();  // 假设 modify() 是非 const 成员函数,且会修改对象状态

// 如果允许隐式转换,则 const_obj 可能被意外修改,违背 const 语义!

编译器的逻辑

  • 常量对象承诺其状态不会被修改。
  • 非 const 成员函数可能修改对象状态(因为 this 是非 const 的)。
  • 若允许常量对象调用非 const 成员函数,相当于允许“通过非 const 指针修改 const 对象”,这是未定义行为(UB)。

因此,编译器直接禁止这种隐式转换。


5-代码示例:显式转换的后果

如果强制使用 const_cast 去除 const 性并调用非 const 成员函数:

const MyClass const_obj;
const_cast<MyClass&>(const_obj).func(10);  // 强制去 const,危险!
此时虽然编译通过,但如果 func 修改了对象状态,程序的行为是未定义的(UB)。编译器不会阻止这种错误,但程序员必须为此负责。


总结

  • 隐式转换的根源:调用非 const 成员函数时,需要将 const ClassName* 转换为 ClassName*
  • 为何禁止:这种转换会破坏常量语义,允许修改本应不可变的对象,违背类型安全。
  • 解决方案:常量对象只能调用 const 成员函数,确保 thisconst ClassName*,从而安全访问对象。

核心原则:C++ 通过严格的类型系统保证常量正确性(const-correctness),防止意外修改常量对象。

类作用域和成员函数

在上述结构中,即使变量定义在“使用该变量的函数”后,函数仍然能够使用该变量。

因为编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。

在类的外部定义成员函数:

显然,在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。

如果成员被声明为常量成员函数,那么它的定义也必须在参数列表后明确指定const属性。同时,类外部定义的成员的名字必须包含它所属的类名:

double Sales_data::avg_price() const {
    if(units_sold)
        return revenue/units_sold;
    else
        return 0;
}

::作为作用域运算符,说明:我们定义了一个名为avg_price的函数,并且该函数被声明在类Sales_data的作用域。因此,revenueunits_sold隐式地使用了Sales_data的成员。

定义一个返回this对象的函数

Sales_data& Sales_data::combine(const Sales_data &rhs){
    units_sold += rhs.units_sold; // 把rhs的成员加到this对象的成员上
    revenue += rhs.revenue;
    return *this; // 返回调用该函数的对象
}

函数combine类似复合赋值运算符+=,调用该函数的对象代表的是赋值运算符左侧的运算对象,右侧运算对象则通过显式的实参被传入函数。

total.combine(trans);

如此调用时,total的地址被绑定到隐式的this参数上,而rhs绑定到了trans上。

combine函数必须引用类型

引用类型 是 C++ 中的一种特殊类型,它本质上是某个已存在变量的“别名”。引用不占用独立内存空间,而是直接绑定到原变量上。例如:

int a = 10;
int &ref = a;  // ref 是 a 的别名,操作 ref 等同于操作 a


一、为什么 combine 函数必须返回引用类型?

1.模仿内置运算符的行为

类似 += 这种复合赋值运算符,其设计是直接修改左侧对象的值,并返回左侧对象的引用(即左值)。例如:

int a = 5, b = 3;
(a += b) += 2;  // 正确:a += b 返回 a 的引用,可以继续操作

如果 combine 返回的是值(而非引用),则无法支持这种链式操作:

total.combine(trans).combine(another_trans);  // 若返回值,第二次调用将操作临时副本,而非原对象!


2.避免不必要的拷贝

如果返回的是值类型(如 Sales_data),函数会生成当前对象的一个临时副本并返回它。这会导致以下问题:

  • 性能损耗:对象较大时,拷贝成本高。
  • 逻辑错误:链式操作中,后续的 combine 会修改临时副本,而非原对象。

而返回引用直接操作原对象,无拷贝开销。


3-保持与内置运算符的一致性

C++ 的赋值运算符(如 =+=)均返回左侧对象的引用,目的是允许连续赋值。例如:

a = b = c;  // b = c 返回 b 的引用,再赋值给 a

同理,combine 返回引用是为了让用户能以自然的方式链式调用:

total.combine(trans).print();  // 直接操作原对象,无需中间副本

二、如果返回的是值类型会发生什么?

假设错误地定义 combine 返回 Sales_data(值类型):

Sales_data Sales_data::combine(const Sales_data &rhs) {  // 返回类型是值!
    units_sold += rhs.units_sold;
    return *this;  // 返回当前对象的拷贝
}

调用时:

total.combine(trans);  // 修改 total 后,返回一个临时副本,但副本未被使用
total.combine(trans).combine(another_trans);  // 第二个 combine 作用在临时副本上,原 total 未被修改!

此时,第二次 combine 操作的是临时副本,原对象 total 的状态不会被更新,导致逻辑错误。


三、总结

  • 引用类型 是原对象的别名,避免拷贝,直接操作原数据。
  • combine 必须返回引用
    • 支持链式操作(如 obj.combine(a).combine(b))。
    • 避免拷贝,提升性能。
    • 与内置运算符行为一致,符合开发者直觉。

如前所述,我们无需使用隐式的this指针访问函数调用者的某个具体成员,而是需要把调用函数的对象当成一个整体来访问:return *this;

3.1.2 定义类相关的非成员函数

类的作者常常需要定义一些辅助函数,尽管这些函数定义的操作从概念上属于类的接口的组成部分,但它们实际上 不属于 类本身。

定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来。如果函数在概念上属于类但不定义在类中,则它一般应该和类声明(而非定义)在同一个头文件内。这样子,只需要引入一个文件。

定义readprint函数

// 输入的交易信息包括 ISBN、售出总数和售出价格
istream &read(istream &is, Sales_data &item)
{
    double price = 0;
    is >> item.bookNo >> item.units_sold >> price;
    item.revenue = price * item.units_sold;
    return is;
}

ostream &print(ostream &os, const Sales_data &item)
{
    os << item.isbn() << " " << item.units_sold << " "
       << item.revenue << " " << item.avg_price();
    return os;
}

read函数从给定流中将数据读到给定的对象里,print函数将给定对象的内容打印到给定的流中。

这两个函数分别接受一个来自IO类型的引用作为参数,这是因为IO类属于不能拷贝的类型,因此只能用引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。

print函数不负责换行。一般而言,执行输出任务的函数一共尽量减少对格式的控制,这样确保由用户代码来决定是否换行。

定义add函数

Sales_data add(const Sales_data &lhs, const Sales_data &rhs){
    Sales_data sum = lhs;  // 把 lhs 的数据成员拷贝给 sum
    sum.combine(rhs);      // 把 rhs 的数据成员加到 sum 当中
    return sum;
}

3.1.3 构造函数

每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做 构造函数(constructor) 。构造函数的任务是初始化对象的数据成员,对象的创建过程都会执行构造函数。

构造函数的名字与类名相同,但没有返回类型,可以有参数列表和函数体;一个类也可以有多个构造函数,不同的构造函数之间必须在参数数量或参数类型上有所区别。

与其他成员函数不同,构造函数不能被声明成const。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才真正取得其“常量”属性。

合成的默认构造函数

Sales_data total, trans;

执行了默认初始化。类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做 默认构造函数(default constructor)

编译器创建的构造函数又被称为 合成的默认构造函数(synthesized default constructor) 。对于大多数类而言,这个函数将按照如下规则初始化类的数据成员:

  • 如果存在类内的初始值,用它来初始化成员。
  • 否则,默认初始化该成员。

某些类不能依赖于合成的默认构造函数

合成的默认构造函数只适合非常简单的类。对于一个普通类来说,必须定义它自己的默认构造函数:

  • 编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数,一旦我们定义了一些其他的构造函数,除非我们定义了默认的构造函数,那么类将没有默认构造函数。

    这条规则的依据是:如果一个类在某种情况下需要控制对象初始化,那么该类很可能在所有情况下都需要控制。

  • 合成的默认构造函数可能执行错误的操作。

    含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户在创建类的对象时就可能得到未定义的值。

  • 有的编译器不能为某些类合成默认的构造函数。

    例如,类中包含一个其他类类型的成员并且这个成员的类型没有默认构造函数。

定义Sales_data的构造函数

我们将使用下面的参数定义4个不同的构造函数:

  • 一个istream&,从中读取一条交易信息。
  • 一个 const string&,表示 ISBN 编号;一个 unsigned,表示售出的图书数量;以及一个 double,表示图书的售出价格。
  • 一个 const string&,表示 ISBN 编号;编译器将赋予其他成员默认值。
  • 一个空参数列表(即默认构造函数),正如刚刚介绍的,既然我们已经定义了其他构造函数,那么也必须定义一个默认构造函数。

给类添加了这些成员之后,将得到

struct Sales_data {
    // 新增的构造函数
    Sales_data() = default;
    Sales_data(const std::string &s): bookNo(s) { }
    Sales_data(const std::string &s, unsigned n, double p):
        bookNo(s), units_sold(n), revenue(p*n) { }
    Sales_data(std::istream &);
    // 之前已有的其他成员
    std::string isbn() const { return bookNo; }
    Sales_data& combine(const Sales_data&);
    double avg_price() const;
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

= default 的含义

Sales_data() = default;

因为该构造函数不接受任何实参,所以它是一个默认构造函数。我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。我们希望这个函数的作用完全等同于之前使用的合成默认构造函数。

在 C++11 新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上 = default 来要求编译器生成构造函数。其中,= default 既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果 = default 在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。

Warning

上面的默认构造函数之所以对 Sales data有效,是因为我们为内置类型的数据成员提供了初始值。如果你的编译器不支持类内初始值,那么你的默认构造函数就应该使用构造函数初始值列表(马上就会介绍)来初始化类的每个成员。

构造函数初始值列表

Sales_data(const std::string &s): bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p):
    bookNo(s), units_sold(n), revenue(p*n) { }

花括号定义了函数体,冒号及冒号和花括号之间的代码称为 构造函数初始值列表(constructor initialize list) ,它负责为新创建的对象的一个或几个数据成员赋初始值:成员名字后面紧跟括号括起来的成员初始值,之间用逗号隔开。

当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。

在类的外部定义构造函数

与其他几个构造函数不同,以istream为参数的构造函数需要执行一些实际的操作。在它的函数体内,调用了read函数以给数据成员赋以初值:

Sales_data::Sales_data(std::istream &is){
    read(is, *this);
    // read函数的作用是从is中读取一条交易信息后,存入this对象中
}

构造函数没有返回类型。

当在类的外部定义构造函数时,必须指明该构造函数是哪个类的成员,而且成员的名字需和类名一致,才说明它是某个类的构造汉纳树。

3.1.4 拷贝、赋值和析构

拷贝(Copy)

拷贝操作通常发生在创建对象副本时。在C++中,有两种拷贝:

  • 拷贝构造函数:当你通过一个已有对象来创建一个新对象时,就会调用拷贝构造函数。例如:

    MyClass obj1;
    MyClass obj2 = obj1;  // 调用拷贝构造函数
    

    如果类中没有显式定义拷贝构造函数,编译器会提供一个默认的拷贝构造函数,它简单地复制每个成员变量的值。

  • 拷贝赋值运算符:当你将一个对象赋值给另一个同类型的对象时,就会调用拷贝赋值运算符。例如:

    MyClass obj1;
    MyClass obj2;
    obj2 = obj1;  // 调用拷贝赋值运算符
    

    如果没有定义拷贝赋值运算符,编译器也会提供一个默认版本。

赋值(Assignment)

赋值操作就是将一个对象的值赋予另一个对象。在C++中,赋值通过赋值运算符(=)实现。赋值运算符可以是编译器生成的默认版本,也可以是用户自定义的。

析构(Destruction)

析构操作发生在对象生命周期结束时,用于清理对象占用的资源。每个类可以定义一个析构函数来执行必要的清理工作。例如:

class MyClass {
public:
    ~MyClass() {
        // 清理代码,如释放内存
    }
};

如果没有定义析构函数,编译器会提供一个默认的析构函数,它不执行任何操作。但是,如果你的类管理了动态内存或其他需要显式释放的资源,那么你必须定义一个析构函数来释放这些资源。

为什么需要自定义这些操作?

  • 资源管理:如果你的类使用了动态内存分配或其他资源(如文件句柄、网络连接等),你需要在析构函数中释放这些资源,以避免内存泄漏或其他资源泄露。

  • 深拷贝与浅拷贝:默认的拷贝构造函数和赋值运算符执行的是浅拷贝(只复制指针),这可能导致多个对象指向同一块内存,从而引发问题。在需要深拷贝(复制实际数据)时,你需要自定义这些函数。

  • 优化性能:在某些情况下,你可以优化拷贝和赋值操作以提高程序性能。