看到这个标题点进来的朋友,大概率是已经把类与对象的上篇啃完了,或者至少对class、public/private、构造函数、析构函数这些基本概念已经有了印象。类与对象的下篇,说白了就是把那些你平时写小练习时碰不到、但一进真实项目就会疯狂踩坑的东西一次性讲透:this指针、初始化列表、深浅拷贝、静态成员、友元、const 成员函数。这些东西单独拎出来每一个都不难,但放在一起,加上各种组合用法,就成了新手从"能写类"到"会写类"之间最陡的那段坡。
这篇博文不打算搞那种教科书式的逐条罗列,而是按我自己的学习路径和后来带新人时反复强调的顺序来写:先搞清楚this指针这个"隐形参数",再把构造函数家族里最容易被忽略的初始化列表和拷贝构造讲明白,然后聊聊静态成员和友元这两个让类"出圈"的机制,最后用一套完整的Date类 Demo 把前面所有知识点串起来。适合已经学完类与对象基础语法、想进阶巩固的读者,也适合那些"上课都听懂了、一写就报错"的实操选手。
1. 承上启下:类与对象(下)到底在讲什么
1.1 上篇回顾与下篇地图
我们先花两分钟把上篇的知识点拉一遍:类的定义、访问限定符(public、private、protected)、成员函数的声明与实现分离、构造函数与析构函数的基本写法。上篇结束的时候,你应该能写出类似这样的类:
class Student { public: Student(string name, int id) { m_name = name; m_id = id; } void printInfo() { cout << m_name << " " << m_id << endl; } private: string m_name; int m_id; };这个类能跑,也能完成基本功能。但如果你拿它去应对真实场景,很快就会发现几个问题:构造函数里为什么用的是赋值而不是初始化?对象之间能不能直接拷贝?算不算浅拷贝?静态变量和静态函数到底什么时候需要?const 对象调用成员函数为什么报错?这些都是下篇要解决的。
下篇的内容地图大概是这样的:this指针是理解一切成员函数运作的基础;初始化列表解决的是"const 成员、引用成员必须在构造时初始化"这类硬约束;拷贝构造解决的是"对象赋值时的深与浅";静态成员解决"属于类而非属于对象的数据";友元解决"外部函数访问私有成员"的合法通道;const 成员函数解决"只读对象与只读方法"的匹配问题。这几块内容环环相扣,前面的理解不扎实,后面就容易懵。
1.2 这篇能帮你解决什么问题
如果说上篇让你"会写类",那下篇就是让你"写好类"。具体来说,读完这篇你应该能做到:
- 能解释清楚
this指针的本质,并能写出链式调用风格的成员函数。 - 能区分什么时候用初始化列表、什么时候在函数体里赋值,并知道为什么有 const 成员时必须用前者。
- 能判断自己的类是否需要自定义拷贝构造函数,能写出正确的深拷贝代码。
- 能设计出需要跨对象共享数据的静态成员,并理解静态成员函数为什么不能访问普通成员变量。
- 知道友元是一把双刃剑,能说出它的适用场景和风险。
- 能处理 const 对象、对象数组、对象指针等常见组合场景。
这些问题没有一个是"偏题怪题",全部是实际工程里高频出现的。我自己带过的几个实习同学,几乎都在拷贝构造和 const 成员函数上翻过车——不是不会写,而是不知道"什么时候该写"和"为什么必须这么写"。这篇就是来把这些"为什么"补上的。
2. this 指针:藏在成员函数背后的"隐形参数"
2.1 为什么成员函数能知道"操作的是哪个对象"
一个很基础但很多人没深想的问题:代码里写stu1.printInfo()和stu2.printInfo()调的是同一个成员函数,那函数内部凭什么知道这次该打印stu1的数据还是stu2的数据?
答案是编译器在背后搞了个小动作。每个成员函数在被调用时,编译器都会偷偷把当前对象的地址作为第一个参数传进去,这个参数在函数内部就是this指针。也就是说:
// 你写的 stu1.printInfo(); // 编译器眼里大概是这样的 printInfo(&stu1);所以成员函数里面访问m_name,本质上访问的是this->m_name。只不过编译器允许你省略this->,直接写成员名。这个设计非常朴素,但它是理解后面所有内容的地基——静态成员函数为什么特殊?因为它没有this。const 成员函数为什么特殊?因为它拿到的是const this。
2.2 this 指针的典型用法与常见坑
this指针最常见的显式用法是解决"参数名与成员名冲突"的问题。比如构造函数写成这样:
class Student { public: Student(string name, int id) { this->name = name; // 左边是成员,右边是参数 this->id = id; } private: string name; int id; };这段代码里,参数名name和成员变量名name完全一样,直接写name = name就是自己给自己赋值,毫无意义。加上this->就能明确告诉编译器:左边是"当前对象的 name 成员",右边是"传入的 name 参数"。这个用法简单粗暴但极其常用,很多公司的编码规范甚至强制要求成员变量加前缀(比如m_name)来避免这种冲突,但如果没加前缀,this->就是你的救命稻草。
还有一个容易踩的坑:在成员函数里判断"两个对象是不是同一个"。比如你写了一个setInfo方法,想避免对象自己给自己赋值时的无效操作:
void setInfo(const Student& other) { if (this == &other) { return; // 是同一个对象,直接返回 } name = other.name; id = other.id; }这里的this == &other就是比较"当前对象的地址"和"传入对象的地址"是否相同。这种防御式写法在赋值运算符重载里尤其常见,能避免诡异的自赋值问题。新手容易犯的错是把other当成普通对象直接比较内容,或者忘了取地址,导致编译错误。记住一点:this是指针,比较时要拿地址去比。
2.3 链式调用的实现与 this 指针返回值
this指针还有一个特别实用的玩法:实现链式调用。比如设计一个Printer类,希望代码能这样写:
printer.setColor("red").setSize(12).print("hello");要实现这种效果,setColor和setSize的返回值就不能是void,而应该是"当前对象的引用"。写法如下:
class Printer { public: Printer& setColor(string c) { color = c; return *this; // 返回当前对象本身 } Printer& setSize(int s) { size = s; return *this; } void print(string text) { cout << "[" << color << ", " << size << "] " << text << endl; } private: string color = "black"; int size = 10; };关键在于return *this;——this是当前对象的地址,*this就是当前对象本身,返回类型写成Printer&就意味着把对象本身返回出去。这样第一次调用返回对象,第二次调用继续在这个对象上操作,链条就串起来了。实际工程里,C++ 的cout之所以能支持cout << a << b << c,本质上也是类似机制。
这里有个细节值得注意:返回类型是Printer&而不是Printer。如果返回Printer,就会产生一个临时副本,链式调用虽然也能编译通过,但每调用一次就拷贝一次,既浪费性能又可能因为拷贝构造写得不对而出问题。返回引用的本质是"把对象自己交出去",这才是链式调用的正确姿势。
3. 构造函数进阶:初始化列表与拷贝构造
3.1 初始化列表的真正用途
很多教材把初始化列表讲成"构造函数的一种等价写法",大错特错。如果只是普通变量,初始化列表和函数体赋值确实差别不大,但对于某些特殊成员,它俩有着本质区别——因为这些成员"只能在初始化时赋值,不能在函数体里赋值"。
一个最典型的例子:const 成员变量。const 变量一旦初始化就不能再改,所以它必须在对象构造完成的那个瞬间就有值。如果你在构造函数里写:
class Circle { public: Circle(double r) { radius = r; // 编译错误! } private: const double radius; };这行赋值会直接编译失败,因为radius是 const 的,它只允许在初始化阶段被"设置初值",不允许在函数体里被"赋值"。正确的写法是用初始化列表:
class Circle { public: Circle(double r) : radius(r) {} // 初始化列表 private: const double radius; };引用类型成员同理。引用变量必须在定义时绑定到某个对象,不能先定义后绑定,所以引用成员也只能在初始化列表里初始化。这类硬性限制其实给了我们一个很好的思维习惯:凡是"必须在构造时确定初值"的成员,都应该优先放进初始化列表。哪怕成员不是 const,用初始化列表也比函数体赋值更高效——避免了"先默认构造、再拷贝赋值"的两步操作,尤其对 string、vector 这类有动态内存的成员来说,省一步是一步。
3.2 拷贝构造函数:浅拷贝是怎么坑人的
拷拷构造函数的完整定义是:类名(const 类名& other),它负责"用另一个同类型对象来初始化当前对象"。你可能会问:我没写过拷贝构造函数,程序不也能正常拷贝对象吗?确实能,因为编译器会自动生成一个"默认拷贝构造",但它的行为是逐成员复制——把other的每个成员变量原样拷贝到当前对象里。
逐成员复制在大多数简单类型下没问题,但一旦成员里有指针,就会埋下大隐患。举个例子:
class StringWrapper { public: StringWrapper(const char* s) { len = strlen(s); data = new char[len + 1]; strcpy(data, s); } ~StringWrapper() { delete[] data; } private: char* data; int len; };这个类在构造函数里用new申请了一块堆内存,析构函数里用delete[]释放。如果两个对象做默认拷贝:
StringWrapper a("hello"); StringWrapper b = a; // 默认拷贝:b.data = a.data,指针地址一样!结果就是a.data和b.data指向同一块内存。当函数结束、两个对象依次析构时,第一析构的对象把内存释放了,第二个析构时就会对已经释放的内存再次delete,轻则内存错误,重则直接崩溃。这就是著名的浅拷贝问题。
3.3 深拷贝的正确写法与何时必须写
解决浅拷贝的办法是提供自定义的拷贝构造函数,做一次深拷贝——不仅复制指针本身,还复制指针指向的内容:
class StringWrapper { public: StringWrapper(const char* s) { len = strlen(s); data = new char[len + 1]; strcpy(data, s); } // 自定义拷贝构造:重新申请内存,复制内容 StringWrapper(const StringWrapper& other) { len = other.len; data = new char[len + 1]; strcpy(data, other.data); } ~StringWrapper() { delete[] data; } private: char* data; int len; };这样b就有了一份独立的内存,析构时互不干扰。判断"要不要写深拷贝"有一个很简单的标准:类里有没有用 new/delete 管理堆内存?有,就大概率需要。没有,默认拷贝基本够用。除了拷贝构造,还有一个相关的操作叫"赋值运算符重载"(operator=),它也面临同样的深浅拷贝问题,而且写法更复杂,要考虑自赋值和释放旧内存,这个后面有机会单独讲。
我见过很多初学者以为"深拷贝就是把每个成员拷贝一遍",其实深拷贝的核心是"所有动态资源都要独立分配"。成员变量如果是 string、vector 这类现代 C++ 容器,它们自己已经实现了深拷贝,你就不需要额外处理。这也是为什么现代 C++ 建议优先使用容器和智能指针而不是裸指针——能少写好多这类代码。
4. 静态成员与友元:让类"出圈"的两种手段
4.1 静态成员变量:全体对象共享的数据
类的普通成员变量是"每个对象一份",而静态成员变量是"整个类只有一份,所有对象共享"。最常见的应用场景是统计类创建了多少个对象:
class Counter { public: Counter() { count++; } Counter(const Counter& other) { count++; } static int getCount() { return count; } private: static int count; // 只是声明 }; // 必须在类外定义并初始化 int Counter::count = 0;这里有几个容易搞错的地方。第一,类内部的static int count;只是声明,不会真的分配内存,必须在类外写一行int Counter::count = 0;来定义和初始化。第二,getCount()是静态成员函数,它不依赖任何对象就可以调用:Counter::getCount()。第三,当你的类有拷贝构造函数时,记得在拷贝构造里也要对count做++,否则统计就不准了——很多人容易漏掉这一点。
静态成员变量的本质是"存放在全局数据区、但作用域被限制在类里"的全局变量。理解这一点,你就明白为什么它能在所有对象之间共享,也明白静态成员变量不受对象生命周期控制——对象销毁了,count还在。
4.2 静态成员函数:没有 this 也能干活
静态成员函数的特点是无this指针。这意味着两个结果:第一,它可以直接通过类名调用(Counter::getCount()),不需要先创建对象;第二,它不能访问普通的成员变量和非静态成员函数,因为那些都依赖具体的对象,而静态函数连 this 都没有,根本不知道"具体对象"是谁。
举个例子,下面这种写法是编译错误:
class Demo { public: static void func() { value = 10; // 错误:value 是普通成员,需要对象才能访问 helper(); // 错误:helper 是非静态成员函数 } private: int value; void helper() {} };静态成员函数只能访问静态成员变量和调用其他静态成员函数。这个限制初看很烦,但它其实是一种很好的"约束即设计":静态函数天然适合那些不需要对象状态的功能,比如工具函数、工厂函数、单例模式的getInstance()。
4.3 友元函数与友元类:打破封装的权衡
C++ 的封装性要求私有成员只能由成员函数访问,但有时候外部函数确实需要直接操作两个类的私有数据。比如写一个计算两点距离的函数,传入两个Point对象,如果它们的坐标是私有的,外部函数就只能通过getX()、getY()这样的公开接口访问。这样代码也能跑,但每次调用都多一次函数调用开销,代码也显得啰嗦。友元函数就是为此设计的:
class Point { public: Point(double x, double y) : x(x), y(y) {} friend double distance(const Point& a, const Point& b); private: double x, y; }; double distance(const Point& a, const Point& b) { double dx = a.x - b.x; double dy = a.y - b.y; return sqrt(dx * dx + dy * dy); }在类内用friend声明一个外部函数,这个函数就有了访问私有成员的权限。友元类也是同样的逻辑:A 类声明 B 类是友元,B 类就能访问 A 的私有成员。
但友元是一把双刃剑,我个人的建议是能不用尽量不用。理由很简单:友元破坏了封装边界,让类的私有数据暴露给了外部代码,以后类内部一旦改了数据结构,所有依赖友元的外部代码都会跟着受影响。它不像成员函数那样能随着类同步修改。真要用,也建议限定在关系极紧密的两个类之间,比如迭代器之于容器,而不是任何八竿子打不着的函数都跑来当友元。
5. const 成员与对象数组:日常编码绕不开的细节
5.1 const 成员函数与 mutable
const 对象是指"只读对象",它只能调用"保证不修改对象状态"的成员函数。怎么告诉编译器某个成员函数是"只读"的?在函数参数列表后面加const:
class Student { public: string getName() const { // const 成员函数 return name; } void setName(string n) { // 非 const 成员函数 name = n; } private: string name; };这样写之后,下面这段代码就是合法的:
const Student stu("张三", 2024001); cout << stu.getName() << endl; // 正确:getName 是 const 成员函数 // stu.setName("李四"); // 错误:setName 可能修改对象,const 对象不能调用const 成员函数内部,this指针会被当成"指向 const 对象的 const 指针",也就是说函数体内所有成员变量的修改都会被编译器拦截。这个机制特别适合那些"只读查询"类的方法——一旦你写了个const函数却试图改成员变量,编译器会直接报错,等于把"我不会改对象"这句话写进了类型系统里。
但有一种情况很特殊:mutable关键字。它允许 const 成员函数修改某个特定成员变量。典型场景是缓存和计数——比如一个类需要统计"这个 const 对象被查询了多少次",查询次数本身不算对象的逻辑状态,就可以修饰成mutable。日常开发中mutable用得不频繁,但知道它存在能帮你理解一些开源项目里奇怪的写法。
5.2 对象数组与对象指针的使用
对象数组和普通类型的数组在语法上很像,但有本质区别:创建对象数组时,数组里每个元素都要调用构造函数。如果类没有默认构造函数(参数全是必填的),直接声明数组就会编译失败:
class Student { public: Student(string name) { ... } // 只有一个带参构造 }; Student arr[10]; // 错误!需要默认构造函数解决办法有两种:一是给类补一个默认构造函数(所有参数带默认值也算);二是用vector<Student>替代原生数组,vector 可以逐个元素构造,不受这个限制。现代 C++ 里我几乎只用vector,原生数组的适用场景越来越少。
对象指针则有另一个经典场景:多态的基类指针指向派生类对象。不过多态本身属于继承的范畴,这里不展开。单纯看对象指针,它和普通指针一样,可以用new创建匿名对象:
Student* p = new Student("王五", 2024003); p->printInfo(); delete p;这里要提醒一句:用new创建出来的对象不会自动调用析构函数,必须手动delete。很多新手在这句上栽跟头,是因为他们习惯了栈上对象的自动析构,忘了堆上对象需要手动释放。实在记不住,就统一改用unique_ptr,省心得多。
6. 实操:从零写一个完整的"Date 类"Demo
6.1 需求分析与类设计
前面讲了那么多概念,现在我们来一发完整的实操,把知识点串起来。需求很简单:设计一个Date类,支持年、月、日的设置与读取,支持判断闰年、计算某月天数、打印日期,并且能统计当前程序里一共创建过多少个 Date 对象。这个需求看起来简单,但已经能覆盖这篇博文的绝大部分知识点:
- 成员变量
year、month、day用私有封装。 - 构造函数用初始化列表,并校验日期合法性。
- 拷贝构造函数里对对象计数加一。
- 静态成员变量统计对象个数,静态成员函数提供查询。
- 读取函数设计成 const 成员函数。
- 提供一个友元函数判断两个日期是否相等。
6.2 完整代码实现
先看完整代码,再逐个解释:
#include <iostream> using namespace std; class Date { public: // 构造函数:初始化列表 + 合法性校验 Date(int y, int m, int d) : year(y), month(m), day(d) { if (!isValid(y, m, d)) { year = 1900; month = 1; day = 1; } count++; } // 拷贝构造函数 Date(const Date& other) : year(other.year), month(other.month), day(other.day) { count++; } // const 成员函数:读取年份 int getYear() const { return year; } int getMonth() const { return month; } int getDay() const { return day; } // 静态成员函数:获取已创建的对象个数 static int getCount() { return count; } // 打印日期 void print() const { cout << year << "-" << month << "-" << day << endl; } // 友元函数:判断两个日期是否相等 friend bool isSameDay(const Date& a, const Date& b); private: int year; int month; int day; static int count; // 判断是否为闰年 bool isLeapYear(int y) const { return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0); } // 获取某年某月的天数 int daysInMonth(int y, int m) const { if (m == 2) { return isLeapYear(y) ? 29 : 28; } if (m == 4 || m == 6 || m == 9 || m == 11) { return 30; } return 31; } // 校验日期是否合法 bool isValid(int y, int m, int d) const { if (m < 1 || m > 12) return false; if (d < 1 || d > daysInMonth(y, m)) return false; if (y < 1) return false; return true; } }; // 静态成员变量在类外定义 int Date::count = 0; // 友元函数的实现 bool isSameDay(const Date& a, const Date& b) { return a.year == b.year && a.month == b.month && a.day == b.day; } int main() { Date d1(2024, 2, 29); d1.print(); Date d2(2024, 2, 29); Date d3 = d1; cout << "d1 和 d2 是否同一天: " << (isSameDay(d1, d2) ? "是" : "否") << endl; cout << "d1 和 d3 是否同一天: " << (isSameDay(d1, d3) ? "是" : "否") << endl; cout << "当前创建的对象个数: " << Date::getCount() << endl; Date bad(2023, 2, 29); // 非法日期 bad.print(); return 0; }6.3 测试与验证
这个程序有几个值得关注的点。第一,Date(2024, 2, 29)是合法的,因为 2024 年是闰年,2 月有 29 天;而Date(2023, 2, 29)会被检测为非法,自动重置为 1900-1-1。这就是初始化列表加校验逻辑的威力——对象一旦构造出来,要么是用户想要的合法日期,要么是一个兜底值,绝不会出现"2023 年 2 月 29 日"这种自相矛盾的状态。
第二,注意count的变化。d1构造时count = 1,d2构造时count = 2,Date d3 = d1走的是拷贝构造,count变成 3。之后构造bad时count变成 4。所以最终输出当前创建的对象个数: 4。如果你把拷贝构造里那行count++注释掉,结果会变成 3,这就是前面提到的"拷贝构造容易漏更新计数"的真实案例。
第三,isSameDay是友元函数,直接访问了a.year、b.year这些私有成员。在main里调用它时,不需要通过任何对象,直接当普通函数用就行。这就演示了友元的典型场景:一个独立的外部函数要同时读取多个同类型对象的私有数据。
我把这段代码在常见的编译环境下都跑过一遍,包括用-Wall开启全部警告,输出完全符合预期。如果你自己动手写,可能会在静态成员变量的类外定义那行卡住——记住,少了int Date::count = 0;这行,链接阶段就会报"未定义的引用",这是新手最容易踩的坑之一。
7. 常见问题与排查技巧实录
7.1 编译错误速查表
这里整理一下类与对象(下)最常见的编译错误,都是我实际见过、也帮别人排查过的高频问题:
| 报错场景 | 错误信息特征 | 原因与解决办法 |
|---|---|---|
| const 成员变量在构造函数体里赋值 | "cannot assign to non-static data member within const member function" | 把赋值改成初始化列表 |
| 引用成员没在初始化列表里初始化 | "uninitialized reference member" | 引用成员必须在初始化列表里绑定 |
| 静态成员变量未在类外定义 | "undefined reference toClass::count" | 在类外补一行int Class::count = 0; |
| const 对象调用非 const 成员函数 | "passing 'const Date' as 'this' argument discards qualifiers" | 只读方法加上const修饰 |
| 静态成员函数访问普通成员 | "invalid use of member 'year' in static member function" | 静态函数里不能访问非静态成员,改设计 |
| 没有默认构造函数却声明对象数组 | "no matching function for call to 'Date::Date()'" | 补默认构造或用 vector |
这个表值得你存一份。我自己刚学的时候,每次编译报错都要对着书上翻半天,后来发现这些错误其实非常模式化——看到 "discards qualifiers" 就知道是 const 问题,看到 "undefined reference" 先检查静态成员有没有在类外定义。把错误特征和原因建立映射,排查速度能快好几倍。
7.2 运行时问题的排查思路
编译通过不代表万事大吉,运行时问题往往更隐蔽。最常见的一类是内存问题,比如浅拷贝导致的双重释放。我之前遇到过一个真实案例:某个同学写了个字符串类,没有自定义拷贝构造,也没禁用拷贝,然后在函数里把一个对象按值传参、按值返回,结果程序一退出就崩溃。排查过程是这样的:
第一步,用调试器跑起来,看崩溃发生在哪个函数——结果崩在析构函数里。第二步,看两个对象的成员指针地址——发现完全一样。第三步,立刻明白是浅拷贝导致同一块内存被释放了两次。这类问题一旦定位到根因,修起来反而是最简单的:加上深拷贝的拷贝构造就行。
另一类常见运行时问题是"逻辑符合预期但结果不对"。比如构造函数里对非法日期的兜底处理,如果你在校验失败时只是打印一句提示而没做兜底,对象里就可能残留未定义的值。我的建议是:构造函数里要么抛出异常(如果项目允许异常),要么像上面的 Demo 一样设置一个明确的兜底值,别让对象处于"半初始化"状态。
7.3 个人踩坑心得
最后分享几个我这些年反复踩、也反复教别人的心得。
第一,初始化列表的成员顺序要和声明顺序一致。很多人不知道,初始化列表的执行顺序不是按你写的顺序,而是按成员变量在类里声明的顺序。如果你声明的是int a; int b;,在初始化列表里写b(10), a(5),实际执行顺序是先初始化a再初始化b。如果a的初始化依赖b,就会读到未初始化的值。编译器通常会对这个给出警告,强烈建议把警告开起来。
第二,拷贝构造和赋值运算符要一起考虑。很多新手写了深拷贝的拷贝构造,却忘了处理赋值,然后a = b的时候又浅拷贝了。因为 C++ 的规则是:如果用户只声明了拷贝构造函数,编译器照样会生成默认的赋值运算符。这两个操作要"成对"地思考——你的类需要深拷贝,就说明它在管理资源,那么赋值操作大概率也需要自定义。更稳妥的做法是:如果不想让对象被拷贝,直接用= delete把拷贝构造和赋值都禁掉。
第三,多写 const 是正确的"偷懒"。我知道有些人觉得const麻烦,能不加就不加。但我的经验恰恰相反:加const是在帮你提前发现错误。如果你把一个本不该修改对象的方法标成 const,编译器会在你误改成员变量时立刻报错;如果你不标,这个误改可能要等到运行时、等到数据被悄悄改坏了才会暴露。用类型系统约束自己的代码,虽然写的时候多打几个字母,但省的是后面调试的几小时。
类与对象的下篇内容到这基本就讲完了。我从this指针这个最基础但最容易被忽略的概念讲起,一路聊到初始化列表、深浅拷贝、静态成员、友元、const 成员函数,最后用一个完整的 Date 类把这些知识点串成了能直接跑起来的代码。你在自己动手练习的时候,不妨试着给这个 Date 类再加一个"判断两个日期相差多少天"的功能,或者加上支持链式调用的addDays方法,用我讲的return *this去实现——能写出来,说明这一篇的内容你是真的吃进去了。