需要预先学习:


1. 类与对象

前面学习的 int、double 等内置类型只能表示单个数据。当你想描述一个学生——有姓名、学号、成绩,还会做自我介绍——散落的几个变量无法把"数据"和"操作"组织在一起。C++ 为此提供了类(class)机制,让你可以定义自己的类型。

  • 类(class):一种用户自定义类型,描述一类事物共有的特征(数据)和行为(操作)。
  • 对象(object):类的具体实例(instance),每个对象都拥有自己的一份数据。

比如"人"是一个类——所有人都有名字、年龄,都会打招呼,这些共性是"人"这个类描述的;而张三、李四则是两个具体的对象,他们拥有同样的结构,但名字、年龄各不相同。

类与对象的关系,就像图纸和按图纸造出来的实物:图纸只有一份,不占实际空间;每按图纸造出一个实物,都要占用真实的空间。对应到程序中:类本身不占内存,每实例化一个对象,就会分配一块独立的内存。


2. 类的定义

用 class 关键字定义类:

#include <iostream>
#include <string>

class Person
{
public:                          // 访问权限限定符,有 public、private、protected 三种
    std::string m_name;          // 成员变量
    int m_age;                   // 成员变量
    void say_hello();            // 成员函数声明
};

void Person::say_hello()         // 在类外定义成员函数
{
    std::cout << "你好,我是" << m_name << ",今年 " << m_age << " 岁" << std::endl;
}

类体由两类成员组成:

  • 成员变量(member variable,也叫属性):描述对象拥有的数据。
  • 成员函数(member function,也叫方法):描述对象能做的操作。

注意类定义末尾的分号 ; 不能省略。

注:本文的成员变量统一以 m_ 开头,在成员函数里一眼就能区分成员变量和局部变量;构造函数参数则用普通名字,不再加 _ 后缀。m_ 只是众多命名风格中的一种,很多项目也用 name_ 这样的后缀风格。

public 是访问权限限定符,表示这些成员在类外可以直接访问。为了让本节例子简洁,成员统一使用 public;三种限定符的具体含义和用法见 封装。


2.1 类的实例化

用类创建对象的过程叫实例化(instantiation)。类和内置类型一样都是一种类型,所以实例化的写法和定义变量一致:

int main()
{
    Person zhangsan;                 // 用 Person 类实例化出对象 zhangsan
    zhangsan.m_name = "张三";         // 用 . 访问成员变量
    zhangsan.m_age = 20;
    zhangsan.say_hello();            // 用 . 调用成员函数

    Person lisi;
    lisi.m_name = "李四";
    lisi.m_age = 18;
    lisi.say_hello();

    return 0;
}

编译并运行代码后,看到这样的输出:

你好,我是张三,今年 20 岁
你好,我是李四,今年 18 岁
  • . 是成员访问运算符,用法为 对象名.成员名
  • 每个对象都拥有独立的成员变量:给 lisi.m_age 赋值不影响 zhangsan.m_age
  • 成员函数的代码只有一份,被所有对象共享;调用时编译器会把对象的地址传给成员函数,让函数知道在操作哪个对象

2.2 成员变量

根据修饰方式,成员变量可以分为几类。

1. 普通成员变量

每个对象各有一份,互不影响,也就是上面例子中的 m_name、m_age。

2. 静态成员变量(static)

  • 属于类本身,被所有对象共享,整个程序里只有一份
  • 在类内声明,还必须在类外定义——真正的存储空间在类外分配
  • 通常用 类名::变量名 访问;通过对象访问到的也是同一份
#include <iostream>

class Counter
{
public:
    static int m_count;              // 类内声明静态成员变量
};

int Counter::m_count = 0;            // 类外定义并初始化

int main()
{
    Counter c1, c2;
    Counter::m_count = 5;            // 通过类名访问
    std::cout << c1.m_count << std::endl;
    c2.m_count = 10;                 // 通过对象访问,修改的是同一份
    std::cout << Counter::m_count << std::endl;

    return 0;
}

编译并运行代码后,看到这样的输出:

5
10

3. 常量成员变量(const)

  • 每个对象可以拥有不同的值,但对象创建完成后就不能再修改
  • 必须在构造函数初始化列表中初始化(见下文"构造函数与析构函数")
#include <iostream>
#include <string>

class Person
{
public:
    const int m_id;                  // 常量成员变量
    std::string m_name;

    Person(int id, const std::string& name) : m_id(id), m_name(name) {}
};

int main()
{
    Person zhangsan(1001, "张三");
    std::cout << "id: " << zhangsan.m_id << ", name: " << zhangsan.m_name << std::endl;
    zhangsan.m_id = 1002;            // 错误!常量成员变量不能修改

    return 0;
}

编译时报错:

error: assignment of read-only member 'Person::m_id'

4. 可变成员变量(mutable)

与 const 成员函数配合使用:const 成员函数承诺不修改成员变量,但 mutable 修饰的成员变量不受限制。具体见下文"成员函数"。


2.3 成员函数

成员函数可以在类内声明、类外定义,也可以直接写在类内。写在类外的定义需要用 类名:: 限定,告诉编译器它属于哪个类:

void Person::say_hello()      // Person:: 表示这是 Person 的成员函数
{
    std::cout << "你好,我是" << m_name << std::endl;
}

成员函数可以直接访问本对象的成员变量,不需要把对象作为参数传进来——这是成员函数与普通函数最大的区别。

const 成员函数

在参数列表后加 const,声明这个函数不会修改成员变量:

#include <iostream>
#include <string>

class Person
{
public:
    std::string m_name;
    mutable int m_visit_count = 0;   // 可变成员变量

    void show() const                // const 成员函数
    {
        m_visit_count++;             // mutable 成员变量可以在 const 成员函数中修改
        std::cout << m_name << " 被访问了 " << m_visit_count << " 次" << std::endl;
    }
};

int main()
{
    Person zhangsan{"张三"};
    zhangsan.show();
    zhangsan.show();

    return 0;
}

编译并运行代码后,看到这样的输出:

张三 被访问了 1 次
张三 被访问了 2 次

如果在 show 中试图修改普通成员变量,编译会报错:

void show() const
{
    m_name = "李四";                 // 试图在 const 成员函数中修改成员变量
}
error: no match for 'operator=' (operand types are 'const std::string' and 'const char [7]')

mutable 常用于缓存、访问计数这类"看起来像修改,但不影响对象逻辑状态"的场景。另外,const 对象只能调用 const 成员函数。


3. 构造函数与析构函数

构造函数(constructor)是一种特殊的成员函数,在对象创建时自动调用,负责把对象初始化到可用的状态:

  • 函数名与类名相同
  • 没有返回值类型(void 也不写)
  • 支持重载

析构函数(destructor)在对象销毁时自动调用,负责清理资源(释放内存、关闭文件等):

  • 函数名是 ~ 加类名
  • 没有参数、没有返回值,因此不能重载

先观察它们的调用时机:

#include <iostream>

class Person
{
public:
    Person()
    {
        std::cout << "Person() 被调用" << std::endl;
    }

    ~Person()
    {
        std::cout << "~Person() 被调用" << std::endl;
    }
};

int main()
{
    std::cout << "进入 main" << std::endl;
    {
        Person p;                    // 对象创建,调用构造函数
        std::cout << "p 还活着" << std::endl;
    }                                // 离开作用域,对象销毁,调用析构函数
    std::cout << "离开 main" << std::endl;

    return 0;
}

编译并运行代码后,看到这样的输出:

进入 main
Person() 被调用
p 还活着
~Person() 被调用
离开 main

3.1 默认生成的构造函数

如果你没有写任何构造函数,编译器会自动生成一个默认构造函数(无参数、函数体为空),所以 Person p; 能直接编译。但只要你写了任意一个构造函数(无论带不带参数),编译器就不再自动生成默认构造函数:

#include <string>

class Person
{
public:
    Person(const std::string& name) : m_name(name) {}   // 只写了带参构造函数
    std::string m_name;
};

int main()
{
    Person p;                        // 错误!默认构造函数已不存在

    return 0;
}

编译时报错:

error: no matching function for call to 'Person::Person()'

C++11 起可以用 = default 显式要求编译器生成默认构造函数:

class Person
{
public:
    Person() = default;              // 显式要求生成默认构造函数
    Person(const std::string& name) : m_name(name) {}
    std::string m_name;
};

3.2 不同的初始化方式调用不同的构造函数

给同一个类写多个构造函数(重载),实例化时根据参数自动匹配:

#include <iostream>
#include <string>

class Person
{
public:
    Person() : m_name("未知"), m_age(0)
    {
        std::cout << "调用默认构造函数" << std::endl;
    }

    Person(const std::string& name, int age) : m_name(name), m_age(age)
    {
        std::cout << "调用带参构造函数" << std::endl;
    }

    void show() const
    {
        std::cout << m_name << ", " << m_age << " 岁" << std::endl;
    }

private:
    std::string m_name;
    int m_age;
};

int main()
{
    Person p1;                    // 调用默认构造函数
    Person p2("张三", 20);         // 调用带参构造函数
    Person p3 = {"李四", 18};      // 初始化列表,同样调用带参构造函数
    Person p4 = p2;               // 调用编译器生成的拷贝构造函数
    p1.show();
    p4.show();

    return 0;
}

编译并运行代码后,看到这样的输出:

调用默认构造函数
调用带参构造函数
调用带参构造函数
未知, 0 岁
张三, 20 岁

拷贝构造与移动构造也属于构造函数,用已有对象初始化新对象时会调用它们,详见 移动语义与std-move。


3.3 构造函数初始化列表

构造函数签名后冒号开头的部分叫构造函数初始化列表(member initializer list),它在函数体执行之前完成成员变量的初始化:

Person(const std::string& name, int age) : m_name(name), m_age(age) {}
  • const 成员变量、引用成员变量只能在初始化列表中初始化
  • 成员变量的初始化顺序由它们在类中的声明顺序决定,与初始化列表中的书写顺序无关
  • 初始化列表是直接构造;先默认构造再在函数体中赋值,则多了一次默认构造的开销