第 18 章

类、构造与析构:从圆柱体到 this 指针

用 Cylinder 类建立成员变量与成员函数的分工,讲清 public/private 默认规则、构造函数与编译器默认构造、setter/getter、多文件拆分与 include guard、用指针管理对象、析构函数的调用时机与逆序销毁,以及 this 指针的三种用法。

Classes, Constructors and Destructors: From a Cylinder to the this Pointer

Building a Cylinder class to separate data from behaviour: default access rules, constructors and the compiler-generated default, setters and getters, splitting a class across files with include guards, managing objects through pointers, destructor call sites and reverse-order destruction, and the three uses of this.

首发于 2022.02.17中 / EN
系统课程cppclassesconstructorsdestructorsthis-pointerheader-files
语言
字号
目录
篇内目录

本章是 31 小时长视频的第 18 章,对应 20:17:38–22:33:31。前面一直在用「函数 + 结构体」写程序;从这一章起,数据和行为被装进同一个类型,正式进入面向对象。

这一章在讲什么

课程用一个圆柱体作为贯穿例子:圆柱由半径 rr 与高 hh 决定,可以算底面积 πr2\pi r^2、体积 πr2h\pi r^2 h。这正好对应类的两个部分:

  • 成员变量:描述对象「是什么」,这里是 base_radius、height;
  • 成员函数(方法):描述对象「能做什么」,这里是 volume()。

本章沿着这个例子依次解决六个问题:类怎么声明、对象怎么构造、外部怎么安全地读写数据、代码怎么拆文件、对象怎么用指针管理、对象什么时候被销毁。最后讲 this 指针,它是成员函数内部指向「当前对象」的句柄。

时间轴

时间内容
20:17用 Cylinder 定义第一个类;public/private 与默认私有
20:37构造函数:无返回类型、与类同名、可带参数;编译器默认构造
20:53= default 让编译器生成默认构造
20:59setter 与 getter:在私有成员外提供受控接口
21:09拆成多个文件:constants.h、cylinder.h、cylinder.cpp、include guard
21:30用指针管理对象:栈对象地址、new/delete、->
21:42析构函数:何时被调用、为什么需要释放资源
22:05构造与析构的调用顺序:构造正序、析构逆序
22:11this 指针:打印地址、解决同名冲突、链式调用

类的声明:成员变量与成员函数

const double PI {3.14159265358979323846};

class Cylinder {
public:
    double volume() {
        return PI * base_radius * base_radius * height;
    }

private:
    double base_radius {0.0};
    double height {0.0};
};

几个必须记住的规则:

  1. 类定义末尾要有分号。少写一个 ;,编译器会报出一条看起来毫不相干的错误。
  2. class 的成员默认是 private;struct 默认是 public(第 19 章展开)。
  3. public: 之后直到下一个访问说明符之前,全部是公开的;private: 同理。
  4. 成员函数写在类体内时,可以直接使用成员变量,无论它们是 public 还是 private——因为函数本身就在类的作用域里。

如果在 main 里访问 cylinder1.base_radius,会得到类似

error: 'double Cylinder::base_radius' is private within this context

这正是封装的第一次体现:类内部可以随意使用自己的数据,类外部必须走公开接口。

成员变量不能是引用

成员变量可以是普通变量,也可以是指针,但不能是引用。原因是引用创建时必须绑定到一个对象,而类在声明成员时还没有对象可绑;声明成员时「先留空、以后再赋值」是常态,引用做不到这一点。

构造函数:对象如何被创建

构造函数是编译器在创建对象时自动调用的特殊成员函数,特点很明确:

  • 没有返回类型(连 void 都不写);
  • 名字与类名相同;
  • 可以有参数,也可以没有;
  • 通常用来把成员变量初始化到可用状态。
class Cylinder {
public:
    Cylinder() {                 // 默认构造:不带参数
        base_radius = 2.0;
        height = 2.0;
    }

    Cylinder(double radius_param, double height_param) {  // 带参构造
        base_radius = radius_param;
        height = height_param;
    }

    double volume() { return PI * base_radius * base_radius * height; }

private:
    double base_radius {1.0};
    double height {1.0};
};

调用时,Cylinder c1; 走无参构造;Cylinder c2(10.0, 4.0); 走带参构造。带参构造的参数是按值传递的,函数体里拿到的是副本——对 double 这种基本类型无所谓,但对大对象就要考虑引用(第 15 章)。

编译器会自动补一个默认构造

如果一个类完全没有写任何构造函数,编译器会自动生成一个「空的默认构造函数」:不带参数,函数体为空。所以下面这段能编译:

class Cylinder {
public:
    double volume() { /* ... */ }
private:
    double base_radius {1.0};
    double height {1.0};
};

Cylinder c1;   // 编译器生成的默认构造被调用,成员用花括号初值

但一旦你自己写了任意一个构造函数,编译器就不再生成默认构造。此时 Cylinder c1; 会报错:

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

如果既想自定义构造、又想保留无参构造,可以显式写一个空的,或者用 C++11 的语法让编译器生成:

Cylinder() = default;

= default 的意思不是「什么都不做」,而是「请编译器按默认规则生成」,它比手写空函数更清晰,也不会阻止编译器做优化。

构造函数必须是 public

构造函数若放在 private 区,外部就无法创建对象,编译器会报 is inaccessible。只有一种情况例外:单例模式等设计会故意把构造函数设为私有——那属于后续话题。

setter 与 getter:把成员变量保护起来

把成员变量设为 private 后,外部仍然需要读写它们。标准做法是提供一对公开函数:

class Cylinder {
public:
    double get_base_radius() const { return base_radius; }
    double get_height() const { return height; }

    void set_base_radius(double radius_param) { base_radius = radius_param; }
    void set_height(double height_param) { height = height_param; }

private:
    double base_radius {1.0};
    double height {1.0};
};

注意几点:

  • setter/getter 必须放在 public 区,否则外部调用会得到 is private within this context。
  • getter 加上 const,表示它不修改对象;这是好习惯,也让 const 对象能够调用它。
  • getter 按值返回时,调用方拿到的是副本,改副本不会影响原对象。需要修改必须走 setter。

这套接口的价值在后面的继承里会更明显:外部只依赖「能问半径、能改半径」,不依赖内部到底怎么存。

把类拆到多个文件

所有代码堆在 main.cpp 里很快就会失控。标准的拆分方式是:

constants.h      // const double PI
cylinder.h       // 类声明(成员变量、函数原型)
cylinder.cpp     // 成员函数的实现
main.cpp         // 只负责使用

cylinder.h:

#ifndef CYLINDER_H
#define CYLINDER_H

class Cylinder {
public:
    Cylinder();
    Cylinder(double radius_param, double height_param);
    double volume() const;
    double get_base_radius() const;
    void set_base_radius(double radius_param);

private:
    double base_radius {1.0};
    double height {1.0};
};

#endif

cylinder.cpp:

#include "cylinder.h"
#include "constants.h"

Cylinder::Cylinder() = default;

Cylinder::Cylinder(double radius_param, double height_param)
    : base_radius{radius_param}, height{height_param} {}

double Cylinder::volume() const {
    return PI * base_radius * base_radius * height;
}

三个新语法:

  1. :: 作用域解析运算符:Cylinder::volume 表示「Cylinder 类里的 volume」,告诉编译器这是类内声明的那个函数的实现。
  2. 初始化列表 : base_radius{...}, height{...}:在进入函数体之前完成初始化,比在函数体里赋值更直接。
  3. 头文件保护(include guard):
#ifndef CYLINDER_H
#define CYLINDER_H
// ... 内容 ...
#endif

预处理阶段会把 #include 的内容原样复制进来。如果 cylinder.h 里又包含了 constants.h,而 main.cpp 也直接包含了 constants.h,同一份定义就会出现两次:

error: redefinition of 'const double PI'

#ifndef / #define / #endif 让第二次包含直接跳过。现代 C++ 也可以写 #pragma once,效果相同。

一个容易踩的坑:头文件里留了实现

把成员函数搬到 .cpp 后,头文件里必须只留声明:

double volume() const;        // 对
double volume() const { ... } // 错:头文件中还有定义

两边都保留定义时,编译能过,链接阶段会报:

multiple definition of 'Cylinder::volume() const'

链接器不知道该用哪一个。这个错误很难一眼看出,因为问题出在「两份定义」而不是某一行语法。

用指针管理对象

对象可以放在栈上,也可以放在堆上:

Cylinder c1(10.0, 3.0);              // 栈对象

Cylinder* p_c1 = &c1;                // 指向栈对象
std::cout << p_c1->volume() << "\n"; // -> 自动解引用

Cylinder* p_c2 = new Cylinder(100.0, 10.0); // 堆对象
std::cout << p_c2->volume() << "\n";
delete p_c2;                          // 必须手动释放

-> 运算符等价于「先解引用,再用 .」:

(*p_c1).volume();   // 等价写法,括号不能省

*p_c1.volume() 是错的,因为 . 的优先级高于 *,会先算 p_c1.volume()。

用 new 创建的对象不会自己消失,必须配对的 delete。忘记 delete 就是内存泄漏;对同一个指针 delete 两次则是未定义行为,可能直接崩溃。

析构函数:对象如何被销毁

析构函数在对象生命结束时由编译器调用,语法是在类名前加 ~:

class Dog {
public:
    Dog(std::string_view name) : m_name{name} {
        m_age = new int{2};          // 在堆上分配
    }

    ~Dog() {
        delete m_age;                // 释放构造时分配的内存
        m_age = nullptr;
    }

private:
    std::string m_name;
    int* m_age {nullptr};
};

规则:

  • 析构函数不能有参数,也不能重载;
  • 没有返回值;
  • 一个类只能有一个析构函数。

析构函数在什么时候被调用

  1. 栈对象离开作用域:函数结束、代码块结束时;
  2. 对堆对象执行 delete:delete p; 会先调用析构,再释放内存;
  3. 对象按值传给函数:函数内部有一份副本,函数返回时副本析构;
  4. 函数按值返回局部对象:可能产生一份额外副本并析构,但编译器常做返回值优化(RVO),所以不保证能看到这次调用。

第 3 条有一个必须警惕的后果:默认的拷贝是逐成员浅拷贝。如果成员里有裸指针,两个对象会指向同一块堆内存,析构时各 delete 一次,就是双重释放。要正确处理,需要自定义拷贝构造与拷贝赋值,或者改用智能指针(后续章节)。

构造与析构的顺序

同一作用域里创建多个对象时:

Dog d1("one"), d2("two"), d3("three");

输出顺序是:

Dog constructor called for one
Dog constructor called for two
Dog constructor called for three
Dog destructor called for three
Dog destructor called for two
Dog destructor called for one

构造按声明顺序,析构严格逆序。 这像叠盘子:先放的在最下面,最后放的先拿走。编译器这样安排,是因为后创建的对象可能依赖先创建的对象;先销毁依赖者,才不会留下悬空引用。

this 指针

this 是编译器在每个非静态成员函数里隐式传入的指针,指向当前正在被操作的对象。它有三种典型用法。

1. 打印当前对象的地址

void print_info() const {
    std::cout << "object at " << this << "\n";
}

没有 this 的话,只有调用方才知道对象地址;有了它,成员函数内部也能拿到自己。

2. 区分同名参数与成员变量

void set_name(std::string_view name) {
    this->name = name;   // 左边是成员,右边是参数
}

如果不写 this->,name = name; 只是把参数赋给自己。编译器可能接受这种写法而不报错,但它什么也没做——这是一个非常隐蔽的 bug,因为代码能编译、能运行,只是数据没变。

3. 实现链式调用

让 setter 返回当前对象的引用(或指针),就能连续调用:

class Dog {
public:
    Dog* set_name(std::string_view n) {
        this->name = n;
        return this;
    }
    Dog* set_breed(std::string_view b) {
        this->breed = b;
        return this;
    }
    Dog* set_age(int a) {
        this->age = a;
        return this;
    }

private:
    std::string name;
    std::string breed;
    int age {0};
};

Dog* p = new Dog();
p->set_name("Fluffy")->set_breed("Shepherd")->set_age(4);

也可以返回引用:

Dog& set_name(std::string_view n) { this->name = n; return *this; }

此时调用方用 .:d.set_name("Fluffy").set_breed("Shepherd");。区别只在于句柄是「指针」还是「引用」,链式调用的本质都是「返回自己,让下一个函数继续作用在同一个对象上」。

本章结论

  • class 成员默认 private;struct 默认 public。
  • 成员变量可以是值或指针,不能是引用。
  • 构造函数无返回类型、与类同名;写了任意构造函数后,编译器不再生成默认构造,需要时用 = default。
  • setter/getter 必须 public;getter 加 const,按值返回得到副本。
  • 多文件组织:头文件放声明与 include guard,.cpp 放实现,用 :: 关联。
  • 堆对象必须 delete;-> 等价于 (*p).。
  • 析构函数无参数、不可重载;构造正序、析构逆序。
  • this 指向当前对象,可用于打印地址、消除同名歧义、实现链式调用。

理解检验

1. 为什么类定义末尾那个分号不能省?如果省了会发生什么?

参考答案:类定义是一条声明语句,语法上必须以分号结束,和 int x; 一样。省略后,编译器会把下一条语句当成类定义的一部分来解析。例如紧接着写 int main() {,编译器会报出类似「expected ';' after class definition」或一大串与 main 无关的错误。错误信息往往指向后面的行,所以看到莫名其妙的报错时,先回头看上一个类定义的分号。

2. 一个类只写了带参构造 Cylinder(double, double),此时写 Cylinder c; 为什么编译失败?怎样恢复无参构造?

参考答案:只要用户声明了任意构造函数,编译器就不再隐式生成默认构造函数。Cylinder c; 需要无参构造,而类里只有带两个参数的版本,于是报 no matching function for call。恢复方式有两种:显式写 Cylinder() {},或者写 Cylinder() = default; 让编译器按默认规则生成。后者语义更清楚,也保留编译器优化的空间。

3. cylinder1.base_radius = 10; 报 private 错误,但 volume() 内部同样访问 base_radius 却没问题。为什么?

参考答案:访问控制检查的是「访问发生的代码位置相对于类的作用域」,而不是「访问者是不是对象」。volume() 定义在类体内部,属于类的作用域,因此可以访问所有成员,包括 private。main() 在类外,只能访问 public 成员。这正是封装的意义:类的实现细节对内部开放,对外部封闭,外部必须通过公开接口(如 setter)间接修改。

4. 头文件里同时有 #ifndef 保护和 #include "constants.h",为什么仍然可能报重定义?

参考答案:include guard 防止的是同一个头文件被包含多次。如果 main.cpp 直接 #include "constants.h",而 cylinder.h 也包含了它,两次包含的是同一个文件,guard 会生效,不会重复。但若两个不同的头文件各自定义了同一个实体(例如都在头文件里写 const double PI = ...;,或都在头文件里给了函数体),guard 保护不了——它们不是同一个文件。此时会报 redefinition 或多重定义。正确做法是:头文件只放声明,定义放进 .cpp;C++17 起也可以用 inline constexpr 变量。

5. 构造与析构为什么采用相反的调用顺序?

参考答案:对象可能依赖先创建的对象,所以构造必须按依赖顺序进行(先创建被依赖者)。销毁时要保证「依赖者先消失、被依赖者后消失」,否则被依赖对象已经销毁,依赖者还持有指向它的引用或指针,就会访问悬空数据。因此析构必须严格逆序。这与栈的规则一致:后进先出。

6. this->name = name; 与 name = name; 在 set_name(std::string_view name) 中有什么区别?

参考答案:this->name 明确指成员变量 name,右边是参数 name,于是把参数写入成员,完成了赋值。写成 name = name; 时,两边的 name 都解析为参数本身(局部作用域优先),等于把参数赋给自己,成员变量完全没变。有些编译器会给出 -Wself-assign 之类的警告,但不保证报错;这正是它危险的地方——程序能编译、能运行,只是数据没更新。

与前后章的关系

本章把「数据 + 行为」装进了类,并解决了对象的生命周期问题。下一章先比较 class 与 struct 的默认访问差异,再看对象在内存中的大小与对齐,然后第一次使用继承:在已有类的基础上叠加新特性。本章的构造函数、析构函数与 this 指针,正是继承章节反复使用的三块基石。

This is chapter 18 of a 31-hour single-video course, covering 20:17:38–22:33:31. Until now programs were built from free functions and plain structs; from here on, data and behaviour live inside one type.

What this chapter is about

The running example is a cylinder: it is determined by a radius rr and a height hh, and it supports two computations, the base area πr2\pi r^2 and the volume πr2h\pi r^2 h. That maps directly onto the two halves of a class:

  • member variables describe what the object is (base_radius, height);
  • member functions describe what the object can do (volume()).

The chapter answers six questions in order: how to declare a class, how objects are constructed, how outsiders safely read and write data, how to split code across files, how to manage objects through pointers, and when objects are destroyed. It closes with the this pointer — the handle a member function has on the object it is operating on.

Timeline

TimeTopic
20:17The first class, Cylinder; public/private and the private default
20:37Constructors: no return type, same name as the class, optional parameters; compiler default
20:53= default to ask the compiler for the default constructor
20:59Setters and getters as a controlled interface over private data
21:09Splitting into files: constants.h, cylinder.h, cylinder.cpp, include guards
21:30Managing objects through pointers: addresses of stack objects, new/delete, ->
21:42Destructors: when they run and why resources must be released
22:05Construction in declaration order, destruction in reverse
22:11this: printing the address, resolving name clashes, chained calls

Declaring a class

const double PI {3.14159265358979323846};

class Cylinder {
public:
    double volume() {
        return PI * base_radius * base_radius * height;
    }

private:
    double base_radius {0.0};
    double height {0.0};
};

Rules worth memorising:

  1. The class definition ends with a semicolon. Omit it and the compiler reports an error that usually points at an unrelated later line.
  2. class members are private by default; struct members are public (chapter 19).
  3. Everything after public: is public until the next access specifier; same for private:.
  4. A member function defined inside the class can use every member, public or private, because it is inside the class scope.

Reaching for cylinder1.base_radius from main produces

error: 'double Cylinder::base_radius' is private within this context

That is encapsulation in its simplest form: the class uses its own data freely, outsiders must go through the public interface.

Member variables cannot be references

A member may be a value or a pointer, never a reference. A reference must be bound to an object when it is created, but at the point where members are declared there is no object yet. Classes constantly rely on "declare now, assign later"; references cannot do that.

Constructors: how objects come into being

A constructor is the special member function the compiler calls when an object is created:

  • no return type (not even void);
  • same name as the class;
  • may take parameters or none;
  • normally puts members into a usable state.
class Cylinder {
public:
    Cylinder() {                 // default constructor, no parameters
        base_radius = 2.0;
        height = 2.0;
    }

    Cylinder(double radius_param, double height_param) {
        base_radius = radius_param;
        height = height_param;
    }

    double volume() { return PI * base_radius * base_radius * height; }

private:
    double base_radius {1.0};
    double height {1.0};
};

Cylinder c1; calls the parameterless one; Cylinder c2(10.0, 4.0); calls the two-argument one. Parameters are passed by value, so the body works on copies — harmless for double, worth thinking about for large objects (chapter 15).

The compiler supplies a default constructor

If a class declares no constructor at all, the compiler generates an empty default constructor: no parameters, empty body. So this compiles:

class Cylinder {
public:
    double volume() { /* ... */ }
private:
    double base_radius {1.0};
    double height {1.0};
};

Cylinder c1;   // compiler-generated default constructor, member initialisers apply

But as soon as you declare any constructor, the compiler stops generating the default one. Cylinder c1; then fails:

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

To keep both, write an empty parameterless constructor, or ask the compiler for one:

Cylinder() = default;

= default does not mean "do nothing"; it means "generate the default version". It states the intent more clearly than an empty body and leaves room for compiler optimisation.

Constructors must be public

A private constructor cannot be called from outside, so no object can be created (is inaccessible). The exception is intentional designs such as the singleton, where the constructor is hidden on purpose.

Setters and getters: protecting the data

Once members are private, outsiders need controlled access:

class Cylinder {
public:
    double get_base_radius() const { return base_radius; }
    double get_height() const { return height; }

    void set_base_radius(double radius_param) { base_radius = radius_param; }
    void set_height(double height_param) { height = height_param; }

private:
    double base_radius {1.0};
    double height {1.0};
};
  • Setters and getters must be public, otherwise callers hit is private within this context.
  • Mark getters const: they do not modify the object, and const objects can still call them.
  • A getter returning by value hands back a copy; modifying it does not touch the original, so changes go through setters.

This interface matters even more under inheritance: callers depend on "I can ask for the radius", not on how the radius is stored.

Splitting the class across files

Keeping everything in main.cpp stops scaling quickly. The conventional split is:

constants.h      // const double PI
cylinder.h       // class declaration: members and function prototypes
cylinder.cpp     // member function definitions
main.cpp         // uses the class

cylinder.h:

#ifndef CYLINDER_H
#define CYLINDER_H

class Cylinder {
public:
    Cylinder();
    Cylinder(double radius_param, double height_param);
    double volume() const;
    double get_base_radius() const;
    void set_base_radius(double radius_param);

private:
    double base_radius {1.0};
    double height {1.0};
};

#endif

cylinder.cpp:

#include "cylinder.h"
#include "constants.h"

Cylinder::Cylinder() = default;

Cylinder::Cylinder(double radius_param, double height_param)
    : base_radius{radius_param}, height{height_param} {}

double Cylinder::volume() const {
    return PI * base_radius * base_radius * height;
}

Three new pieces of syntax:

  1. ::, the scope resolution operator. Cylinder::volume means "the volume declared inside Cylinder", telling the compiler which declaration this definition belongs to.
  2. The member initialiser list : base_radius{...}, height{...} initialises before the body runs, which is more direct than assigning inside the body.
  3. Include guards:
#ifndef CYLINDER_H
#define CYLINDER_H
// ... contents ...
#endif

The preprocessor pastes included files verbatim. If cylinder.h includes constants.h and main.cpp also includes it, the same definition appears twice:

error: redefinition of 'const double PI'

The guard makes the second inclusion a no-op. #pragma once is the modern alternative.

A common trap: definitions left in the header

After moving functions to the .cpp, the header must contain declarations only:

double volume() const;        // correct
double volume() const { ... } // wrong: definition still in the header

With both copies present, compilation succeeds and the linker fails:

multiple definition of 'Cylinder::volume() const'

The linker cannot choose between two definitions. It is hard to diagnose because no single line is syntactically wrong.

Managing objects through pointers

Objects live on the stack or on the heap:

Cylinder c1(10.0, 3.0);              // stack object

Cylinder* p_c1 = &c1;                // pointer to a stack object
std::cout << p_c1->volume() << "\n"; // -> dereferences for you

Cylinder* p_c2 = new Cylinder(100.0, 10.0); // heap object
std::cout << p_c2->volume() << "\n";
delete p_c2;                          // must be released manually

-> is shorthand for "dereference, then use .":

(*p_c1).volume();   // equivalent; the parentheses are required

*p_c1.volume() is wrong because . binds tighter than *, so it would try to call p_c1.volume() first.

Anything created with new does not disappear on its own; the matching delete is mandatory. Forgetting it leaks memory; deleting the same pointer twice is undefined behaviour and commonly crashes.

Destructors: how objects are destroyed

A destructor runs when an object's lifetime ends. Its name is the class name prefixed with ~:

class Dog {
public:
    Dog(std::string_view name) : m_name{name} {
        m_age = new int{2};          // heap allocation in the constructor
    }

    ~Dog() {
        delete m_age;                // released in the destructor
        m_age = nullptr;
    }

private:
    std::string m_name;
    int* m_age {nullptr};
};

Rules:

  • a destructor takes no parameters and cannot be overloaded;
  • it has no return type;
  • a class has exactly one destructor.

When the destructor runs

  1. A stack object goes out of scope — end of a function or block;
  2. delete is applied to a heap object — destructor first, then memory is freed;
  3. An object is passed by value — the copy inside the function dies on return;
  4. A local object is returned by value — an extra copy may be created and destroyed, though compilers often elide it (RVO), so you may not observe the call.

Case 3 carries a warning: the default copy is member-wise and shallow. If a member is a raw pointer, both objects point at the same heap block, and each destructor deletes it — a double free. Fixing this requires a user-defined copy constructor and copy assignment, or smart pointers (later chapters).

Construction and destruction order

Creating several objects in one scope:

Dog d1("one"), d2("two"), d3("three");

produces:

Dog constructor called for one
Dog constructor called for two
Dog constructor called for three
Dog destructor called for three
Dog destructor called for two
Dog destructor called for one

Constructors run in declaration order; destructors run strictly in reverse. Think of stacking plates: the first placed is at the bottom, the last placed is removed first. The compiler enforces this because later objects may depend on earlier ones; destroying the dependants first avoids leaving dangling references behind.

The this pointer

this is a pointer the compiler passes implicitly to every non-static member function; it points at the object currently being operated on. Three standard uses:

1. Printing the object's own address

void print_info() const {
    std::cout << "object at " << this << "\n";
}

Without this, only the caller knows the address; with it, the member function can see its own object.

2. Disambiguating a parameter from a member

void set_name(std::string_view name) {
    this->name = name;   // left: member, right: parameter
}

Writing name = name; assigns the parameter to itself. Some compilers accept it silently — the program compiles and runs, but no data changes. This is a genuinely dangerous bug precisely because nothing appears wrong.

3. Chained calls

Returning the current object from a setter allows calls to be chained:

class Dog {
public:
    Dog* set_name(std::string_view n) {
        this->name = n;
        return this;
    }
    Dog* set_breed(std::string_view b) {
        this->breed = b;
        return this;
    }
    Dog* set_age(int a) {
        this->age = a;
        return this;
    }

private:
    std::string name;
    std::string breed;
    int age {0};
};

Dog* p = new Dog();
p->set_name("Fluffy")->set_breed("Shepherd")->set_age(4);

Returning a reference works too:

Dog& set_name(std::string_view n) { this->name = n; return *this; }

The caller then uses .: d.set_name("Fluffy").set_breed("Shepherd");. The only difference is whether the handle is a pointer or a reference; the idea is the same — return yourself so the next call lands on the same object.

Chapter summary

  • class members are private by default; struct members are public.
  • Members may be values or pointers, never references.
  • Constructors have no return type and the class's name; declaring any constructor suppresses the implicit default, so use = default when you need it.
  • Setters and getters must be public; getters are const and return copies by value.
  • Multi-file layout: declarations plus include guards in the header, definitions in the .cpp, linked by ::.
  • Heap objects need delete; -> is (*p)..
  • Destructors take no parameters and cannot be overloaded; construction is in order, destruction in reverse.
  • this points at the current object and serves address printing, name disambiguation and chaining.

Comprehension check

1. Why can the semicolon after a class definition not be omitted, and what happens if it is?

Reference answer: A class definition is a declaration statement and, like int x;, must end with a semicolon. Without it the compiler treats the following statement as part of the class definition. If int main() { comes next, the diagnostic is typically "expected ';' after class definition" plus a cascade of errors that point at main rather than at the class. The practical lesson: when errors appear in a line that looks fine, check the preceding class definition for its semicolon.

2. A class declares only Cylinder(double, double). Why does Cylinder c; fail, and how do you restore the no-argument constructor?

Reference answer: As soon as a user-declared constructor exists, the compiler no longer generates the implicit default constructor. Cylinder c; requires a no-argument constructor, but only the two-argument one exists, so the compiler reports no matching function for call. Either write Cylinder() {} explicitly, or write Cylinder() = default;. The latter states the intent and leaves optimisation opportunities open.

3. cylinder1.base_radius = 10; fails with a private-access error, yet volume() reads the same member without trouble. Why?

Reference answer: Access control is checked at the location of the access, relative to the class scope, not against the object. volume() is defined inside the class body, so it is within class scope and may touch every member, private included. main() is outside the class and may only use public members. That asymmetry is the point of encapsulation: internals are open to the class and closed to everyone else, who must go through a public interface such as a setter.

4. A header has both an include guard and #include "constants.h", yet a redefinition error still appears. Why?

Reference answer: An include guard prevents the same header file from being included twice. If main.cpp includes constants.h and cylinder.h also includes it, that is the same file and the guard works. But if two different headers each define the same entity — for instance both declare const double PI = ...;, or both provide a function body — the guard cannot help, because they are different files. The result is a redefinition or multiple-definition error. The fix is to keep definitions in a .cpp, or use inline constexpr variables in C++17 and later.

5. Why do constructors run forwards and destructors backwards?

Reference answer: Objects may depend on objects created earlier, so construction must follow dependency order (dependencies first). Destruction must guarantee that dependants disappear before what they depend on; otherwise a dependant still holds a reference or pointer to something already destroyed and would touch dangling data. Hence destruction runs strictly in reverse. It is the rule of a stack: last in, first out.

6. In set_name(std::string_view name), what is the difference between this->name = name; and name = name;?

Reference answer: this->name unambiguously names the member, while the right-hand name is the parameter, so the parameter is stored into the member. With name = name;, both sides resolve to the parameter — the local scope wins — so the parameter is assigned to itself and the member is untouched. Some compilers warn (-Wself-assign), but none is required to reject it. The program compiles, runs, and silently fails to update the object.

Relation to neighbouring chapters

This chapter put data and behaviour into one type and settled object lifetimes. The next chapter compares class and struct defaults, examines object size and alignment, and then uses inheritance for the first time: building a new type on top of an existing one. Constructors, destructors and this are exactly the three tools the inheritance chapters keep reusing.

来源