跳转至

3.1 类基础与成员函数

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

类的基本思想是 数据抽象(data abstraction)封装(encapsulation)

  • 数据抽象是一种依赖于 接口(interface)实现(implementation) 分离的编程(以及设计)技术。

    • 类的接口包括用户所能执行的操作;
    • 类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
  • 封装实现了类的接口和实现的分离。

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

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

不同的编程角色

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

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

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

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

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

类的结构

关键字 类名 {
    // 类体
}
  • 关键字
    • struct
    • class // 更普遍的讲法
    • ……
  • 类体:

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

      C++11标准规定,可以为数据成员提供一个 类内初始值(in-class initializer)。 ????非C++11也可以么?可以初始化静态的数据成员?代码实践下呢?PPT里怎么说的?

    • 【方法】成员函数(member function)

    • 性质:
      • 类内部定义的名字必须唯一,但是可以与类外部定义的名字重复。
      • 所有成员都必须在类的内部 声明
      • 其中成员函数体可以 定义 在类内,也可以 定义 在类外

预处理器概述

确保头文件多次包含仍能安全工作的常用技术是 预处理器 (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++ 语言中关于作用域的规则。

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

以struct为关键字的类实现
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;
}

成员函数

  • 成员函数必须在类的内部声明
  • 但既可以在类内定义,也可以在类外定义
Sales_data类的升级
Sales_data.h
#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
main.cpp
#include "Sales_data.h"

// 输入的交易信息包括 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;
}
Sales_data add(const Sales_data &lhs, const Sales_data &rhs)
{
    Sales_data sum = lhs;  // 把 lhs 的数据成员拷贝给 sum
    sum.combine(rhs);      // 把 rhs 的数据成员加到 sum 当中
    return sum;
}

引入this

this 是一个指向当前对象的隐含指针,用于在成员函数中访问当前对象的成员变量和成员函数。

  • 指向当前对象this 指针在成员函数中指向调用该函数的对象。
  • 区分成员变量和局部变量:当成员变量和局部变量同名时,this 可以用来明确访问成员变量。
案例
class MyClass {
private:
    int x;

public:
    void setX(int x) {
        this->x = x;  // 使用 this 指针区分成员变量和局部变量
    }

    int getX() const {
        return x;
    }
};

//……
MyClass A;
  • this 是一个隐含参数,传递给每个非静态成员函数。当我们调用成员函数时,用请求该函数的对象地址初始化 this。

    对于A.getX()而言,编译器把A的地址传递给 getX 的隐式形参this—— MyClass::getX(&A) - this 指针的类型是 类名* const,即指向当前类对象的常量指针。

引入 const

成员函数后面的 const 用于声明该成员函数不会修改对象的状态(即不会修改任何非静态成员变量)。

  • 保证不修改对象状态const 成员函数不能修改对象的成员变量。
  • 允许对常量对象调用:可以对 const 对象调用 const 成员函数。

至此,成员函数又将被分类为:

  • 常量成员函数(const member function)
  • 非常量成员函数

const 成员函数与 this 指针

  1. this 指针的类型

    • 默认情况下,this 指针的类型是 指向类类型的非常量版本的常量指针,即 ClassType* const

      所以不能把this绑定到一个常量对象上,也不能在一个常量对象上调用普通的成员函数(非const成员函数)。

    • 当成员函数被声明为 const 时,this 指针的类型变为 指向类类型的常量版本的常量指针,即 const ClassType* const

  2. const 成员函数的作用

    • const 成员函数保证不会修改对象的成员变量。

    • 因此,const 成员函数可以被常量对象调用,而非 const 成员函数不能被常量对象调用。


案例
class Book {
private:
    std::string isbn;

public:
    std::string getISBN() const {  // 声明为 const 成员函数
        return isbn;
    }

    void setISBN(const std::string& newISBN) {
        isbn = newISBN;  // 修改 isbn,不能声明为 const
    }
};

int main() {
    const Book book("123-456-7890");  // 常量对象
    std::string isbn = book.getISBN();  // 可以调用 const 成员函数
    // book.setISBN("098-765-4321");  // 错误:不能对常量对象调用非 const 成员函数
    return 0;
}
  • getISBN() 是一个 const 成员函数,因此它的 this 指针类型是 const Book* const

  • setISBN() 是一个非 const 成员函数,因此它的 this 指针类型是 Book* const

  • 常量对象 book 只能调用 const 成员函数,因为它的 this 指针不能绑定到非常量对象上。

类作用域

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

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

类X的成员M具有类作用域,对M的访问方式如下:

  • 如果在X的成员函数中没有声明同名的局部作用域标识符,那么在该函数内可以访问成员M。否则,局部变量会隐藏成员变量。
  • 通过表达式x.M或者x::M访问。
  • Ø通过表达式prt->M(将在后续详细讲解)

类外定义:

class 类名 {
    // 类体
    返回类型 函数名(参数列表);  // 成员函数的声明
};

// 类外定义成员函数
返回类型 类名::函数名(参数列表) {
    // 函数体
}

返回this对象

案例
Sales_data& Sales_data::combine(const Sales_data &rhs){
    // 无需使用隐式的`this`指针访问函数调用者的某个具体成员
    units_sold += rhs.units_sold; // 把rhs的成员加到this对象的成员上
    revenue += rhs.revenue;
    return *this; 
    // 返回调用该函数的对象
    // 需要把调用函数的对象当成一个整体来访问
}

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

total.combine(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))。
    • 避免拷贝,提升性能。
    • 与内置运算符行为一致,符合开发者直觉。

非成员函数

在"Sales_data类的升级"中,函数istream &read(……)ostream &print()就属于非成员函数

非成员函数**是独立于类的函数,不属于任何类,也不 **直接 访问类的私有成员。它们可以操作类的对象,但需要通过对象的公共接口(如成员函数)来实现。

  • 独立于类:非成员函数不隶属于任何类。

  • 需要对象的公共接口:如果需要访问类的私有成员,必须通过类的公共成员函数。

  • 定义位置:通常在头文件中声明,在源文件中定义。

案例
// 输入的交易信息包括 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类属于不能拷贝的类型,因此只能用引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。

Tip

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

构造函数

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

构造函数没有返回类型。

合成的默认构造函数

编译器创建的构造函数又被称为 合成的默认构造函数(synthesized default constructor)

对于大多数类而言,这个函数将按照如下规则初始化类的数据成员:

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

默认构造函数

MyClass() = default; // C++11
// 或者
MyClass(){ // 同时可以为数据成员提供类内初始值
    // ……
}

这两种构造函数不接受任何实参,所以它们是一个默认构造函数。

我们定义这个构造函数的目的仅仅是因为我们 既需要其他形式的构造函数,也需要默认的构造函数

其中,= default 既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果 = default 在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。

Warning

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

默认构造函数的规则

  1. 默认构造函数的定义

    • 默认构造函数是一个不接受任何参数的构造函数。

    • 如果类中没有定义任何构造函数,编译器会自动生成一个默认构造函数。

  2. 默认构造函数的失效

    • 如果你定义了至少一个构造函数(无论是默认构造函数还是其他形式的构造函数),编译器将不再生成默认构造函数。

    • 如果你需要默认构造函数,必须手动定义它。

初始值列表

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)

它负责为新创建的对象的一个或几个数据成员赋初始值:成员名字后面紧跟括号括起来的成员初始值,之间用逗号隔开。

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

类外定义构造函数

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

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

构造函数的默认参数

在C++中,构造函数的默认参数应该写在声明部分,而不是定义部分。这是因为默认参数是函数声明的一部分,而不是函数定义的一部分。将默认参数写在声明部分可以确保在函数调用时,编译器能够正确识别和使用这些默认值。

正确的做法:默认参数写在声明部分

class Clock
{
public:
    Clock(int NewH = 0, int NewM = 0, int NewS = 0); // 默认参数在声明部分
    void SetTime(int NewH, int NewM, int NewS);
    void ShowTime();
private:
    int Hour, Minute, Second;
};

// 构造函数的定义
Clock::Clock(int NewH, int NewM, int NewS)
{
    Hour = NewH;
    Minute = NewM;
    Second = NewS;
}

错误的做法:默认参数写在定义部分

class Clock
{
public:
    Clock(int NewH, int NewM, int NewS); // 没有默认参数
    void SetTime(int NewH, int NewM, int NewS);
    void ShowTime();
private:
    int Hour, Minute, Second;
};

// 构造函数的定义
Clock::Clock(int NewH = 0, int NewM = 0, int NewS = 0) // 默认参数在定义部分
{
    Hour = NewH;
    Minute = NewM;
    Second = NewS;
}

为什么默认参数应该写在声明部分

  1. 接口清晰:默认参数是函数接口的一部分,应该在声明中明确表示,这样用户在调用时就能清楚地看到有哪些默认值。
  2. 一致性:C++标准要求默认参数在函数声明中指定,而不是在定义中。将默认参数放在定义部分可能会导致编译器行为不一致。
  3. 避免重复:如果默认参数同时出现在声明和定义中,可能会导致重复和维护问题。

拷贝构造函数

拷贝构造函数(Copy Constructor)是用于通过同类型的另一个对象来初始化新对象的特殊构造函数。

  • 作用:用已有对象初始化新对象。
  • 形式类名(const 类名& other)
    (参数通常为 常引用,避免修改原对象)
class MyClass {
public:
    int* data;

    // 拷贝构造函数
    MyClass(const MyClass& other) {
        data = new int(*other.data); // 深拷贝
    }
};

&

一定要加&

1. 避免拷贝构造函数的无限递归

如果拷贝构造函数的参数是按值传递的对象,那么在调用拷贝构造函数时,编译器需要先创建一个临时对象作为参数传递给构造函数。然而,创建这个临时对象又会调用拷贝构造函数,导致无限递归,最终引发编译错误。

2. 提高效率

按值传递参数会创建一个临时副本,而按引用传递参数则不会创建临时副本,直接使用原对象的地址。这可以避免不必要的拷贝操作,提高程序的运行效率。

3. 保护原对象

使用常量引用(const 类名&)可以确保在拷贝构造函数中不会修改原对象的状态。这样可以保证原对象的完整性,避免在拷贝过程中意外修改原对象的数据。

4. 符合语义

拷贝构造函数的目的是用一个已存在的对象来初始化一个新对象,而不是修改原对象。使用常量引用可以更好地表达这一语义。

默认拷贝构造函数与深/浅拷贝

若未显式定义拷贝构造函数,编译器会生成一个 默认拷贝构造函数

  • 默认行为
    对每个成员进行 浅拷贝(逐成员复制)。
    若成员为指针,则仅复制指针地址,不复制指向的数据
// 默认拷贝构造函数的浅拷贝示例
MyClass obj1;
obj1.data = new int(10);

MyClass obj2 = obj1; // obj2.data 和 obj1.data 指向同一内存
*obj2.data = 20;     // obj1.data 的值也被修改!
  • 浅拷贝:仅复制指针地址(默认行为)。
    风险:多个对象共享同一资源,可能导致重复释放内存。
  • 深拷贝:复制指针指向的数据。
    实现方式:需手动定义拷贝构造函数,重新分配内存。
// 深拷贝实现
class MyClass {
public:
    int* data;

    MyClass(const MyClass& other) {
        data = new int(*other.data); // 为新对象分配独立内存
    }

    ~MyClass() { delete data; }
};

三种“拷贝场景”

  1. 显式初始化
    MyClass obj1;
    MyClass obj2 = obj1; // 调用拷贝构造函数
     // MyClass obj2(obj1);
    
  2. 函数传参(按值传递对象):
    void func(MyClass obj); 
    func(obj1); // 实参到形参的拷贝
    
  3. 函数返回对象(按值返回):

    MyClass createObj() {
        MyClass obj;
        return obj; // 可能调用拷贝构造函数(受编译器优化影响)
    }
    

    Tip

    objcreateObj()的局部对象,离开建立它的函数createObj()以后就消亡了,不可能在返回主函数后继续存在。处理这种情况编译器会在主函数中创建一个临时的无名对象,它的生存期只在函数调用处的表达式中,执行return obj;时,实际上是调用拷贝构造函数将A的值拷贝到临时对象中。

    赋值操作符重载函数则是用一个已存在的类对象去赋值给另一个已存在的对象,即只更新其内容或值,而不是像拷贝构造函数那样去构造一个新的对象

析构函数

完成对象被删除前的一些清理工作。在对象的生存期结束的时刻系统自动调用它,然后再释放此对象所属的空间。如果程序中未声明析构函数,编译器将自动产生一个默认的析构函数。

#include<iostream>
using namespace std;
class Point{
public:
    Point(int xx,int yy);
    ~Point();
    ... //其它函数原形
private:
    int X,int Y;
};

Point::Point(int xx,int yy){
    X=xx;
    Y=yy;
}

Point::~Point(){
}

...
copy、assignment & destruction

——拷贝、赋值与析构

拷贝(Copy)

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

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

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

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

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

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

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

赋值(Assignment)

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

析构(Destruction)

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

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

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