☰
深入理解C++的this指针与const成员函数
2026/10/24 1:13:04 网站建设 项目流程

C++里的成员函数,藏着很多新手看不到的细节。今天聊最基础也最容易绕晕的两个点:隐藏的this指针,以及const修饰的类成员函数。很多人写过class、天天调this->xxx,可一旦遇到error: passing 'const Demo' as 'this' argument discards qualifiers,就开始发懵。这篇文章不是给你背语法条文,而是把编译器在背后做的事、const 成员函数的作用机制、以及项目里常见的坑一次讲透。

如果你在学 C++,或者写过类但总觉得底层不踏实,这篇正好适合你。读完之后,你能自己解释“为什么成员函数里可以不加修饰直接访问成员变量”,也能说清楚“const 成员函数到底限制了什么、没限制什么”,同时会处理各种因为 this 指针和 const 导致的编译错误。

1. this指针:藏在成员函数背后的隐式参数

1.1 为什么说this是“隐藏”的

在类里写成员函数,最直观的感受是什么?在函数内部可以直接写成员变量,不需要任何参数传递,也不需要额外声明,好像成员变量天生就长在函数里。比如下面这段:

class Point { public: void setX(int value) { x = value; } private: int x; };

你把x = value;这行单独挑出来,放在普通函数里就是一句非法代码,因为根本没有 x 这个变量。但在成员函数里它居然成立,原因就是编译器偷偷往这个成员函数里塞了一个参数,这个参数就是 this 指针。成员函数本质上是“这个类的普通函数 + 一个指向对象地址的隐式参数”,你平时写的那些成员函数,最后都会被编译器重写成这样一个普通函数。

我打个比方:你把对象想象成教室,成员变量是教室里的黑板、桌椅,成员函数是“管理员来打扫教室”这个行为。学校给管理员的工牌上不会写教室里的每一个物品,只写教室地址,管理员进教室后根据地址找到教室,自然就能看到桌椅、黑板。this 指针就是那个“教室地址”,只要拿到了它,函数内部就能定位到这个对象的每一个成员变量。

1.2 编译器是如何重写成员函数的

为了让脑中的模型更精确,我们可以把手里的 C++ 源码往底层挪一步。以下两个函数的声明,在 C++ 编译器的眼里几乎是等价的:

// 源代码:看起来很干净的成员函数 void Point::setX(int value); // 编译器内部模型:一个普通函数,多带了对象指针 void setX(Point* this, int value);

当你写下obj.setX(10);的时候,编译器实际生成的调用代码类似于:

setX(&obj, 10);

也就是说,obj的地址被作为第一个实参传进了函数。这个地址在函数内部就成了 this 指针。所以“隐藏”不是玄学,只是语法糖,你写的时候少敲几个字符,编译器帮你把参数补上而已。更重要的是,这个转换是编译期完成的,不是运行时魔法,不会额外影响你的类对象大小。

我经常看到有人问“this指针存在对象的哪个位置”,答案是它根本不存进对象里。对象的内存里只保存成员变量,this 指针是一个函数参数,通常放在寄存器中。在常见的 x86-64 调用约定下,this 指针往往通过第一个整数寄存器传递。你可以暂时理解为“函数在调用时顺路带了个地址进去”,这个地址不会越俎代庖占用对象内存。

1.3 this指针的真实类型是什么

this 指针既然是一个隐式参数,那它自然有类型。在一个非 const 的非静态成员函数里,this 指针的类型是:

Point* const

注意,这里的关键在于顶层 const,也就是这个指针自身指向不能被修改,你不能让 this 指向另一个对象,但你可以通过这个指针修改它所指向的对象内容。换句话说,this->x = 10是合法的,this = nullptr这种写法在源码里根本不允许。

在一个 const 成员函数里,this 指针的类型会变成:

const Point* const

这就有意思了:不仅指针本身不能改,指针指向的内容也被标记为 const。所以编译器会拒绝通过这个 this 修改任何非 mutable 成员变量。const 修饰类成员函数,本质上并不是什么神秘的运行时保护,而是编译期把你手里的对象指针从“可写类型”换成了“只读类型”。后面我会单独展开讲。

2. 用一段代码和反汇编,看懂this的真实面目

2.1 先写一个简单类验证地址

理论讲多了容易虚,直接上代码。先定义一个有构造函数的类,在构造函数里打印 this 的地址,在 main 里打印对象地址,看看是不是同一个:

#include <iostream> class Demo { public: Demo(int v) : value(v) { std::cout << "构造函数中的 this = " << this << std::endl; } int get() const { return value; } private: int value; }; int main() { Demo d(42); std::cout << "main 中的对象地址 = " << &d << std::endl; std::cout << "d.get() = " << d.get() << std::endl; return 0; }

输出结果里,这两行打印的地址完全一致。原因很明显:构造函数在初始化对象时,编译器传进去的也是&d,this 指向的就是正在构造的那块内存。所以你可以在构造函数里直接通过this操作对象,因为它就是调用方那个对象的真实地址。

有人可能还会问,多个对象调用同一个成员函数时,函数代码只有一份,是怎么区分不同对象的数据的?答案就在 this 参数上。同一个成员函数的二进制代码只有一份,但每次调用时传入的 this 地址不同,函数内的成员变量访问最终都会通过this + 偏移量来定位。这也是 C++ 实现“同一套逻辑作用于不同实例”的核心。

2.2 用C语言模拟成员函数

如果你学过 C,就会知道 C 语言里没有类,但有结构体,也有函数指针。我在刚接触 C++ 时做过一个练习:用 C 语言的方式模拟 C++ 成员函数,效果非常好,因为一旦写出等价代码,隐藏的 this 指针就“不隐藏”了。

#include <stdio.h> struct Point { int x; int y; }; // 相当于成员函数 void point_setX(struct Point* self, int x) { self->x = x; } // 相当于 const 成员函数 int point_getX(const struct Point* self) { return self->x; } int main() { struct Point p = {0, 0}; point_setX(&p, 10); // 等价于 p.setX(10) printf("%d\n", point_getX(&p)); // 等价于 p.getX() return 0; }

这段代码里,self这个名字是我自己起的,但它扮演的正是 this 的角色:指向调用对象。C++ 只是把point_setX(&p, 10)包装成了p.setX(10),把self->x = x包装成了x = x。很多时候你感觉 C++ 的成员函数“有魔力”,是因为编译器替你完成了这个包装,而你自己没有在纸上展开过。

展开之后再看 const 成员函数就清楚多了:C++ 里int get() const与 C 语言里int point_getX(const struct Point* self)是一回事。const 加在函数签名末尾,等价于给传入的 self 指针指向的内容加 const。这就是为什么 const 成员函数可以读成员变量,但不能写。

2.3 通过反汇编看 this 到底怎么传

如果你有一点点汇编基础,可以借这个话题偷偷看一眼成员函数背后的代码。拿最简单的 x86-64 环境举例,当调用obj.setValue(5)时,调用方通常会把对象地址放到一个寄存器里,把参数 5 放到另一个寄存器,然后 call 成员函数的入口地址。

成员函数内部访问value时,会在调用约定指定的寄存器里取出 this 指针,再根据成员变量偏移量来读写内存。举个例子,如果类里只有一个 int,那value大概率位于对象地址偏移 0 处,所以函数内部很可能是“从寄存器取出地址,然后直接对这个地址进行写入”。这比你去访问一个普通全局变量还容易,因为它完全不需要计算全局符号地址,只需要用传进来的对象地址。

这也解释了另一个新手容易迷糊的点:成员函数放在类里,但是不会导致每个对象都保存一份函数代码。普通成员函数和静态成员函数一样,都是存在代码段里的,区别只是普通成员函数多接收一个隐式的 this 参数。对象内存里只保存成员变量,函数指针以及函数代码并不存在对象内部。

2.4 空指针调用成员函数的危险实验

很多新手会听到一个段子:用空指针调用成员函数,只要函数内部不碰成员变量,程序可能不会立刻崩溃。这个说法在实现层面确实有依据,但在 C++ 标准里属于未定义行为。我来演示一下这个危险实验,但强调一下,以下代码仅供理解,永远不要在实际项目中这样写:

#include <iostream> class Safe { public: void hello() { std::cout << "hello" << std::endl; } void printValue() { std::cout << value << std::endl; } private: int value{100}; }; int main() { Safe* p = nullptr; // 危险:未定义行为。某些编译器下会正常输出,因为成员函数没有访问 this 指向的内存 p->hello(); return 0; }

为什么p->hello()可能不崩溃?因为hello()的代码虽然把 this 放进了某个寄存器,但函数内部根本没有通过 this 访问任何对象成员,它只是调用了一个无关的插入操作符。编译器没有理由去强行解引用 this,所以程序侥幸不崩。但如果把上面printValue()的调用也加上,一旦函数内部访问value,就等于去地址 0 附近读内存,通常会直接触发段错误。

这个例子一定要当成“理解”用,千万不要当成“技巧”用。因为标准把空指针调用任何非静态成员函数都定义为未定义行为,编译器可以生成任何代码,它可以在你调用hello()时直接崩溃,也可以输出一段奇怪的东西。在开启优化之后,编译器甚至可能利用“this 不可能为空”这个假设,把空值判定优化掉,导致你完全摸不着头脑。

3. const修饰成员函数:编译器给this套上的紧箍咒

3.1 const成员函数的语法到底怎么摆放

先回答最基础的问题:const 放在哪里?正确的语法是放在成员函数的参数列表后面、函数体前面。比如:

class Student { public: std::string getName() const { return name; } private: std::string name; };

如果是类外定义,声明和定义都要在相同位置写上 const:

class Student { public: std::string getName() const; }; std::string Student::getName() const { return name; }

很多初学者会把const当成返回值修饰的一部分,写成const std::string getName(),然后用疑的眼神看它,这其实是完全不同的东西。左侧的 const 修饰的是返回值本身,右侧的 const 修饰的是成员函数。两者可以同时出现:

const std::string& getName() const;

这个函数意思是:返回一个 const 字符串引用,同时该函数本身不修改对象。一个 const 在左,管的是“交出去的东西”;一个 const 在右,管的是“我内部怎么做”。这些细节在签名重载时非常重要,因为右侧 const 参与函数重载,左侧 const 只影响返回类型,不参与区分重载。

3.2 this类型的转变是理解const函数的关键

前面已经提到,const 成员函数中 this 类型从ClassName* const变成了const ClassName* const。这句话再展开一点:

  • 非 const 成员函数,this 是Teacher* const this,你能通过 this 修改对象。
  • const 成员函数,this 是const Teacher* const this,你不能通过 this 修改该对象。

所以,当你在 const 成员函数里写下value = 100;,编译器看到value等价于this->value,它发现 this 指向的是 const 对象,立刻拒绝编译。搞清楚这一点,你就能解释“为什么 const 成员函数不能调用非 const 成员函数”:因为非 const 成员函数要求 this 类型是Teacher*,而当前环境中的 this 类型是const Teacher*,把一个 const 指针传给需要非 const 指针的函数,类型不匹配。

这就像你在一个只读的证件上盖了“仅查看”的章,任何要求“可修改”的操作都会被前台拒之门外。不是这个对象本身真的是只读的,而是当前调用的上下文只给你一个只读视角。

3.3 mutable:const函数里唯一能修改成员的合理后门

有些成员变量在逻辑上不属于对象的“状态”,比如调试用的访问计数、统计数据、缓存结果、线程安全用的互斥锁等。为了在这些场景下允许 const 成员函数修改某个变量,C++ 提供了mutable关键字:

class Server { public: int getPort() const { ++accessCount_; // mutable 变量可以在 const 函数中修改 return port_; } int getAccessCount() const { return accessCount_; } private: int port_; mutable int accessCount_ = 0; };

这里的accessCount_是“多少次查询了端口”,它虽然是成员变量,但修改它并不会改变端口配置这个核心状态。用mutable修饰之后,哪怕整个函数是 const,也能给它累加。实际项目中,mutable最常用在缓存、统计、锁等场景。

但我要提醒一句:mutable是工具,不是遮羞布。如果你觉得某个成员变量需要经常改,又嫌 const 碍事,就随手加 mutable,那 const 成员函数就名存实亡了。正确姿势是:先假设对象状态由所有非 mutable 成员变量决定,只有那些不影响对象“可观察状态”的后台变量才考虑用 mutable。

3.4 const成函数与非const成函数的重载规则

const 既是函数签名的一部分,也就能参与重载。一个类里可以同时存在下面两个函数:

class Buffer { public: int& operator[](size_t index) { return data_[index]; } const int& operator[](size_t index) const { return data_[index]; } private: int data_[1024]; };

调用哪一个版本,取决于对象本身是否是 const:

  • 非 const 对象调用operator[],走第一个重载,拿到的是int&,可以读也可以写。
  • const 对象调用operator[],只能走第二个重载,拿到的是const int&,只读。

这种“const 版本 + 非 const 版本”的组合,在标准库容器里到处都是,比如std::vector、std::map、std::string。你写代码时几乎感觉不到它的存在,但一旦把容器传给一个以 const 引用方式接收的函数,容器元素就自动降级为只读,这就是靠第二个重载完成的。

反过来要小心一种情况:类里只有非 const 版本时,const 对象不能调用。因为调用非 const 版本需要把const Buffer*当作Buffer*传进去,类型不安全,编译器直接拒绝。以后看到“const 对象无法调用成员函数”的报错,先检查是不是没写 const 版本。

4. 在真实项目里怎么用好const成员函数

4.1 设计接口时的const纪律

很多 C++ 项目的代码,隔三差五就冒出十几处编译错误,原因往往是接口设计阶段没定好 const。反过来讲,如果一开始读函数都加 const,写函数不加 const,后续使用会顺畅得多。我建议你养成一个习惯:成员函数只要能不改对象,就写成 const。

举个例子:

class Order { public: int getId() const; double totalPrice() const; bool isValid() const; void markPaid(); };

getId、totalPrice、isValid都不修改订单状态,它们都应该是 const。这里的好处在于,任何以const Order&接收订单的服务函数,都能自由调用这三个方法。如果你把isValid()漏写了 const,那么哪怕你的逻辑完全没改数据,const Order&照样不能调用,必须去改调用方,或者被迫把 const 引用去掉,这就是最常见的 const 传染。

想象一个系统把订单传给审计模块,审计模块接收const Order&,里面的函数要判断订单是否有效,结果isValid不是 const 函数,编译直接失败。要解决这种问题,要么给审计模块也去掉 const,要么把方法补上 const。去掉 const 的代价是审计模块可能意外修改订单,补 const 的代价几乎为零。所以,能 const 就 const,是成本最低的设计纪律。

4.2 返回成员变量时,记得配合const引用

一个细节点容易被忽略:成员函数返回成员变量的引用时,const 版本和非 const 版本要配套。比如有个类持有配置项:

class Config { public: std::string& getName() { return name_; } const std::string& getName() const { return name_; } private: std::string name_; };

非 const 版本返回std::string&,意味着外部拿到引用后可以直接修改内部成员,这在某些场景是有意为之。const 版本返回const std::string&,只允许外部读取,不允许修改。如果 const 版本返回的是std::string&,那么即便你能拿到 const 对象,也可以通过返回的引用改掉成员内容,const 就形同虚设了。

一个容易踩的坑是:只写了非 const 版本,没有写 const 版本。当 const 对象调用getName()时,编译器找不到可用的重载。如果你确定调用方只需要读,建议优先提供 const 版本,在必要时再补非 const 版本。

4.3 操作符重载中的this和const

操作符重载在工程里非常常见,其中 this 指针的作用最明显的就是返回*this实现链式调用。来看一个简单的赋值集合的例子:

class Builder { public: Builder& add(int v) { values_.push_back(v); return *this; } Builder& reverse() { std::reverse(values_.begin(), values_.end()); return *this; } const std::vector<int>& result() const { return values_; } private: std::vector<int> values_; };

这里的Builder& add(int v)返回的是对象引用,而return *this;就是通过 this 指针解引用得到整个对象。return 引用并不会复制对象,所以可以一直链式下去:

Builder b; b.add(1).add(2).add(3).reverse();

如果函数返回的是Builder而不是Builder&,每次调用都会做一次对象拷贝,效率很低且语义危险。this 指针在这个场景下既是“数据定位器”,也是“链式编程的开关”。

同样的原理也用在常见的operator<<、operator=里。例如赋值运算符返回引用就是为了让a = b = c合法,并且避免无谓拷贝。在这些成员函数里,this 指针的存在感最强,因为return *this;已经写在你的眼前了。

4.4 拷贝构造与赋值中的const成员函数

拷贝构造函数和拷贝赋值运算符的参数通常写成const Type&,这是为了能用常量对象作为源,同时也避免拷贝。再来看看成员函数签名:

class Money { public: Money(const Money& other) : amount_(other.amount_) {} Money& operator=(const Money& other) { amount_ = other.amount_; return *this; } private: int amount_; };

在拷贝构造函数里,other.amount_是通过 const 对象访问私有成员变量。这里有一个冷知识:类的成员函数可以访问任意同类对象的私有成员,包括 const 对象。other虽然是const Money&,但Money的拷贝构造函数可以访问other.amount_,因为访问控制是基于类,而不是基于对象实例。同时,因为other是 const 引用,内部真正访问成员时走的仍然是“只读”路径。

这也是为什么 const 引用传参在 C++ 里这么重要。如果你拷贝构造函数只写Money(Money& other),那么不能用 const 对象去拷贝;如果又顺手把成员函数全部漏写 const,那大多数代码都会变得寸步难行。所以别再觉得 const 成员函数只是语法约束,它在整个接口体系里是一块地基。

5. 新手必看:const成员函数相关的坑与排查

5.1 最常见的 error:passing this argument discards qualifiers

很多人的第一次崩溃,是从这段代码开始的:

const Demo d; d.setValue(10);

编译器报错信息类似:

error: passing 'const Demo' as 'this' argument discards qualifiers [-fpermissive]

原因已经再明显不过:setValue是非 const 成员函数,它接收的 this 类型是Demo*,但d是 const 对象,实际能提供的 this 类型是const Demo*。从const Demo*到Demo*属于去掉了 const 限定,编译器当然拒绝。

排查方式分三步:

  1. 看对象变量本身是不是 const,或者是不是通过 const 引用调用。
  2. 看成员函数声明末尾有没有 const。
  3. 如果确实需要修改对象,考虑去掉 const;如果不需要修改,就把成员函数补上 const。

很多人卡在第 3 步,因为一看到 error 就慌,脑子里只想着“怎么绕过编译器”,结果把 const 引用改成可写引用,引发一连串连锁改动。正确做法是问自己:这个成员函数的本质是读还是写?读就加 const,写就别用 const 对象调用它。

5.2 const 放错位置的经典误解

新手最常见的低级错误,是把 const 放在返回值上,然后以为自己是 const 成员函数:

class Demo { public: // 这是错的!const 不是这样用来修饰成员函数的 const void func() { // ... } };

这段代码可以编译,但它修饰的是返回值的 const 属性,而不是成员函数的 const 属性。也就是说,你并没有把 this 变成const Demo*,在函数内部依然可以修改成员变量。如果你想写 const 成员函数,必须把 const 放在参数列表之后:

class Demo { public: void func() const { // 这个位置才是正确的 } };

另外,const void func()这种写法本身也很怪异,因为一个 void 返回值的 const 修饰基本没有意义。如果你在项目里看到别人这么写,建议提醒他,这不是省事,而是挖坑。

5.3 构造函数、析构函数、静态成员函数不能加const

有些初学者会好奇:构造函数能不能写成Demo() const?答案是不能。原因是构造函数在创建对象,此时对象尚未完成初始化,编译器不允许通过 const 版本的 this 去初始化成员。析构函数同样需要修改对象内部状态(释放资源、清理状态),也不能加 const。

静态成员函数更不能加 const。因为静态成员函数不隶属于任何对象,它没有 this 指针。既然没有 this,自然不能通过 this 类型去施加 const 限制。如果你写了静态成员函数加 const,编译器会直接提示静态成员函数不能带 cv 限定符。

从设计上看,这三个限制其实都指向同一个事实:const 是给“已经存在的对象”使用的观察者视角。构造和析构都处在对象的生命周期边界,静态函数根本没有对象视角,所以它们和 const 天然冲突。

5.4 不要随便用const_cast绕过const

我明白在项目急急忙忙上线时,看到const报错很容易想用const_cast一锤子解决。但我要认真劝一句:不到万不得已别用const_cast去修改一个本来就是 const 的对象。

const Demo d(42); Demo& ref = const_cast<Demo&>(d); ref.setValue(0); // 危险!如果 d 本身是 const 对象,这是未定义行为

这里 d 在源码层面是 const 对象,编译器可能把它的成员变量放在只读数据段,也可能利用“这个对象不会变化”做优化。你用const_cast强行拿到可写引用再修改,程序可能暂时跑对了,也可能莫名其妙崩溃。真正的正确用法是:只有当变量本身不是 const,只是当前上下文给了一个 const 引用时,才能通过const_cast去掉这层临时限制。比如从 const 引用参数里拿到底层对象的可写引用,且明确知道这个对象源自一个非 const 变量。

相比之下,如果要保证 const 成员函数里某些字段仍可修改,推荐用mutable而不是const_cast。mutable是类型系统认可的例外,编译器知道它特殊,代码语义也清楚;const_cast等于强行打破约束,一旦打破,后面所有保证都会失效。

5.5 排查小技巧:用 const 引用重载逼出正确版本

如果你在调一个复杂类,分不清哪个成员函数是 const、哪个不是,我分享一个排查技巧:写一个模板函数或者普通函数,接收 const 引用参数,然后在里面调用你怀疑的方法。比如:

void checkReadOnly(const Demo& d) { std::cout << d.get() << std::endl; // 如果这里编译通过,说明 get 是 const 成员函数 // 如果编译失败,说明 get 没有 const 版本 }

这个方法不用修改业务代码,直接利用编译器当裁判。如果一个类有 const 和非 const 两个重载,用 const 引用调用自然走 const 版本,测试起来非常干净。我在重构老项目时常用这个方式快速定位哪些接口缺了 const,效率很高。

最后分享一个我自己的实际体会

我在做 C++ 项目时,最吃亏的阶段就是刚接触 const 成员函数的头几周。那时候总觉得加 const 限制太多,碍手碍脚,后来被一个专门查代码规范的人盯上,逼着把所有只读接口全部改成 const。改完之后我再回头看,才真正明白 const 成员函数的意义:它不是给编译器找麻烦,而是给未来的自己和协作者提供一份“这个函数不会改对象”的书面承诺。对象能看清自己的边界,调用方也能更放心地把对象交给别人,整个工程的编译错误反而少了一大截。

如果你也想快速提升对 C++ 的理解,我建议你亲自动手做两件事。第一,用 C 语言模拟一个带 this 指针的类,感受一下成员函数展开后的样子。第二,把你自己项目里的读接口逐一圈出来,加上 const,然后看编译会揪出多少处不合理的调用。这两步做完,你对 this 和 const 成员函数的理解会比看十篇文章都扎实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询