
我最早学 C 的时候最困惑的不是语法本身而是类到底凭什么比结构体高级。后来项目写多了才慢慢摸透类其实是对现实事物的一种建模方式它的核心价值不是让你把变量和函数堆在一起而是帮你控制谁能动、怎么动、什么时候动这些数据和逻辑。这篇算是我自己对类和对象前半部分不含继承和多态的一次复盘整理适合刚接触 C 的同学对照着敲代码也适合学了半年还说不清 this 指针和 const 成员函数区别的朋友查漏补缺。我会沿着为什么要类、怎么构造对象、对象怎么存储、常见坑怎么排查这条线往下走每段都有可以直接跑的例子也会把我踩过的坑标注出来。代码都是 C11 及以后的标准编译环境可选 Visual Studio 或者 VSCode MinGW-g下面会单独说环境配置时容易踩的坑。1. 类与对象的基本概念从结构体到类的进化1.1 先回忆一下 struct 的痛点很多人觉得 C 的类就是多了函数的 struct这个说法对了一半。C 语言的 struct 确实可以放数据但它管不住谁能改这些数据。比如你要做一个学生信息表struct Student { char name[32]; int age; float score; };在 C 语言里你在任何地方都可以直接stu.age -5不会有人拦你。等到程序跑起来你才发现年龄变成负数这时候只能靠人工记住年龄不能小于 0这种约定。数据一旦分散在多个文件里靠约定就是灾难。类带来的第一个改变就是把数据和操作数据的方法打包成一个整体并且通过访问控制把不合法的修改挡在门外。1.2 类的定义与访问控制封装是怎么来的先看一份最常见的类定义格式#include iostream #include cstring class Student { private: char name[32]; int age; float score; public: void setName(const char* n) { std::strncpy(name, n, 31); name[31] \0; } void setAge(int a) { if (a 0 a 120) { age a; } } void printInfo() const { std::cout Name: name , Age: age , Score: score std::endl; } };关键在于三个访问限定符private、protected、public。初学者最容易记混的其实是默认权限struct默认 publicclass默认 private。也就是说你把上例的class换成structname、age就变成任何人可以直接读写的公有成员。这个差异不是语法怪癖而是设计意图的反向体现C 语言里结构体本来就是开放的数据集合C 的类默认倾向于谨慎先藏起来再说。1.3 封装的意义把怎么做藏起来我常用空调遥控器来类比封装。你按一下制冷键空调内部是压缩机的启停、温度传感器读值、风速调节这些细节对使用者完全不可见。类里面的 private 部分就相当于空调内部的电路public 的成员函数相当于遥控器上的按钮。你通过setAge()设置年龄函数内部做合法性判断外界永远绕不开这个检查。如果你把 age 直接设成 public就等于把空调的电路板拆出来让用户自己拿螺丝刀捅出问题只能自己兜着。封装带来的另一个好处是修改成本可控。比如某天业务要求年龄上限从 120 改成 150你只需要改setAge()内部那一行判断所有调用方都不用动。如果没有封装所有直接写stu.age xxx的代码你都得逐个排查这是我在真实项目里体会很深的一点。2. 构造函数对象一出生就要做的事2.1 构造函数的形式与重载构造函数的名字和类名一模一样没有返回值也不能写 void。它的职责是让对象在出生那一刻就处于一个可用状态。构造函数可以重载也就是同一个类可以有多个不同参数的构造函数编译器根据你创建对象时传入的参数个数和类型来匹配。class Date { private: int year, month, day; public: Date() { year 1970; month 1; day 1; } Date(int y, int m, int d) { year y; month m; day d; } }; Date d1; // 调用无参构造 Date d2(2025, 3, 16); // 调用带参构造这里有个常见认知误区Date d1;这种写法才是调用无参构造而Date d1();会被编译器解析成一个函数声明返回类型是Date参数为空。我第一次在代码里写Date d1();然后怎么都调不出成员函数卡了很久才明白是最令人头疼的语法解析most vexing parse在捣乱。2.2 初始化列表为什么高效构造函数有两种给成员赋初值的方式函数体内赋值以及初始化列表。看起来结果差不多但底层路径完全不同。class Point { private: const int id; // const 成员 int x, y; public: Point(int i, int px, int py) : id(i), x(px), y(py) { } };这里id是 const 成员它的值只能在初始化列表里设置在函数体里赋值会直接编译报错。原因很简单const 成员初始化之后就不能再修改而构造函数体执行时成员已经完成初始化你再赋值就相当于初始化后又修改。即使没有 const 成员初始化列表也比函数体内赋值少一次先默认构造、再拷贝赋值的额外开销尤其是类里有 string、vector 这类重量级对象时差距是实打实的。但初始化列表也有一个经典的坑赋值顺序不是按列表书写顺序而是按成员声明顺序。例如class Test { private: int a; int b; public: Test() : b(10), a(b) { } };因为 a 先声明所以 a 先初始化此时 b 还没有被赋值为 10a 拿到的是一个未定义值。这个 bug 我遇到过不止一次排查时往往以为是编译器的问题其实只是没理清声明顺序。2.3 默认构造函数与突然消失的构造如果你定义一个类一个构造函数都不写编译器会隐式生成一个默认构造函数它什么也不干。看起来很方便但坑在于一旦你自己写了任何一个构造函数哪怕是有参构造编译器就不再生成无参构造函数。class Config { public: Config(const std::string path) { // ... } }; Config c; // 编译错误不存在默认构造函数这个错误对新手来说很迷惑因为 class 看起来明明什么都没写错。解决办法就是明确写出无参构造函数或者给有参构造的参数都设定默认值。我个人的习惯是如果类里没有必须校验的状态就主动写一个Config() default;让意图清晰也避免之后同事接手时踩同样的坑。3. 析构函数与资源管理3.1 析构函数的作用有生就有死。析构函数负责在对象生命周期结束时释放它占用的资源函数名是类名前加~无参数、无返回值、不能重载。栈上的对象在离开作用域时自动调用析构堆上new出来的对象需要你手动delete才会调用析构。class Buffer { private: int* data; std::size_t size; public: Buffer(std::size_t n) : size(n) { data new int[n]; } ~Buffer() { delete[] data; data nullptr; } };在这个例子里如果忘了析构函数每次new Buffer(1024)都会泄漏 4KB 内存。服务器程序挂着跑一晚上内存占用肉眼可见地往上涨最后系统 OOM 被杀这就是资源泄漏的直观后果。3.2 析构函数要注意的细节析构函数虽然简单但有两个细节值得记下来。第一析构函数的调用时机栈对象在作用域结束处反向调用析构顺序上遵循先构造的后析构这个规律在对象之间互相依赖时特别关键。如果你有对象 A 依赖对象 B那么应该先构造 A 再构造 B这样析构时先析构 B 再析构 A避免出现A 释放资源前 B 已经销毁的悬垂场景。第二析构函数里不要再手动调用delete其他已经被 RAII 管理的对象。比如某个类内部有一个std::vector成员vector 自己的析构会释放内部缓冲区你不必在析构里对 vector 的 data 指针做任何操作做了反而是二次释放、直接崩溃。初学者最容易在这个地方过度热心。4. 对象的创建、存储与 this 指针4.1 三种对象栈上、堆上、全局根据创建位置不同对象分三类栈对象、堆对象、全局/静态对象。它们各自的生命周期差别很大但初学者往往忽略这一点。class Demo { public: Demo() { std::cout 构造 std::endl; } ~Demo() { std::cout 析构 std::endl; } }; Demo globalObj; // 全局对象程序启动时构造退出时析构 int main() { Demo stackObj; // 栈对象 Demo* heapObj new Demo(); // 堆对象必须手动 delete delete heapObj; return 0; }运行这段代码你会发现全局对象在 main 执行之前就构造了栈对象在 main 内的作用域结束时析构堆对象只有delete之后才析构。理解这个关系是排查很多诡异 bug 的基础。比如全局对象的构造函数里用了某个尚未初始化的第三方库程序可能在进入 main 之前就崩溃这类问题在项目里非常难查因为断点还没设上人就没了。4.2 this 指针的本质在类内部访问成员变量编译器其实偷偷塞了一个隐藏参数就是 this 指针。它指向调用这个成员函数的那个对象。class Counter { private: int count 0; public: Counter increase() { this-count; return *this; } void print() const { std::cout count std::endl; } }; int main() { Counter c; c.increase().increase().increase(); c.print(); // 输出 3 }this 最常见的用途有两个。一是成员参数名与成员变量名冲突时用this-x x;区分二是需要返回对象自身来实现链式调用例如c.increase().increase().increase()。但要特别注意this 是一个右值指针你不能this也不能把这赋给另一个对象的成员它是编译器在调用时绑定到当前对象的。4.3 对象数组与对象指针C 允许声明对象数组语法和内置类型数组一样但要注意两点对象数组要求类有无参构造函数或带默认参数因为数组初始化时没有参数可用堆上分配对象数组要用new[]释放要用delete[]不能和delete混用。Demo arr[5]; // 栈上对象数组调用5次无参构造 Demo* heapArr new Demo[5]; // 堆上对象数组 delete[] heapArr; // 必须配 delete[]不能是 deletenew和new[]的底层缓存池往往是分开管理的如果你用delete释放new[]出来的内存可能不会立刻崩溃但一定会在运行一段时间后出现堆损坏位置还飘忽不定。我调试过的崩溃案例里这类配错删除方式的问题仅次于空指针。5. const 成员函数、静态成员与类对象的进阶问题5.1 const 成员函数谁能改、谁不能改在成员函数声明后面加const表示这个函数不会修改对象内部状态。它的存在不是为了炫技而是为了让 const 对象也能安全调用。class Point { private: int x; mutable int cacheHitCount 0; public: int getX() const { cacheHitCount; // mutable 成员可以在 const 函数里修改 return x; } };有一个很容易混淆的规则一个指针类型的成员变量const成员函数只是不能修改指针本身但能修改指针指向的目标。换句话说const 的修饰是浅层的。如果内部有int* data在 const 成员函数里写data[0] 42是合法的。这层含义往往要等到了解深拷贝的时候才会体会到。5.2 静态成员变量与静态成员函数静态成员是属于类而非属于某个对象的变量它只有一份副本所有对象共享。类的静态变量必须在类外单独定义这一点经常被初学者忽略class Account { private: static double rate; public: static void setRate(double r) { rate r; } }; double Account::rate 0.05; // 类外定义分配内存静态成员函数没有 this 指针所以它不能访问非静态成员变量也就意味着静态成员函数里想操作某个对象必须通过参数把对象传进来。这个限制符合逻辑没有 this你就不知道该操作哪个对象的成员。5.3 类与对象、抽象类和普通类的关系很多热词搜索里能看到类与对象的区别抽象类和普通类的区别。就 C 来说类定义了一类事物的属性与行为对象是类的一个具体实例。Student是类某个具体的张三是对象这个区别落实到代码上就是Student 不占资源定义声明一个 Student 变量才产生对象。至于抽象类它和普通类的本质区别在于有没有纯虚函数。包含纯虚函数的类无法实例化只能作为基类被继承。这部分严格讲属于类和对象下里继承与多态的范畴这里先记住结论普通类可以直接创建对象抽象类不行。我见过不少人在没有虚函数基础时硬啃抽象类越看越晕正常路径还是先把构造、this、const 这些地基打牢。6. 实际编码中常见的坑与排查方法6.1 表达式必须包含类类型是最常见报错之一这个报错几乎每个 C 初学者都会遇到。先看一段会出问题的代码class Item { public: void print() const {} }; int main() { Item item; item.print(); // 正确item 是对象 Item* ptr item; ptr.print(); // 错误ptr 是指针要用 ptr-print() }ptr.print()会触发表达式必须包含类类型因为ptr是一个指针它没有成员函数print()。解决办法就是用箭头-解引用。这个错误背后其实是指针和对象的区分问题。我排查这类错误时第一眼不是看报错行而是先看那个变量到底是对象还是指针很多时候问题根本不在函数名拼写而在访问方式。6.2 内部类的分类与使用场景C 里没有 Java 那样严格的内部类体系但支持嵌套类nested class。嵌套类可以在外部类里声明它的访问权限受外部类的访问控制影响。比如class Outer { private: int secret; public: class Inner { public: void visit(const Outer o) { // 成员访问权限按类独立计算 } }; };嵌套类的实用性在于把概念上强相关的类型放在一起。比如一个文件解析器FileParser它的私有数据格式ParsedHeader可以作为嵌套类写在里面外部想引用就得写FileParser::ParsedHeader代码结构一目了然。但注意嵌套类不会自动获得外部类的普通成员访问权它只是名字嵌套在类里不是内部拥有外部对象。6.3 对象数组去重的几种做法处理对象数组去重和内置类型数组不同因为对象比较默认没有等于操作你得先定义什么算相等。#include algorithm #include vector class Student { public: int id; std::string name; }; bool operator(const Student a, const Student b) { return a.id b.id a.name b.name; } int main() { std::vectorStudent students { {1, Tom}, {2, Jerry}, {1, Tom} }; std::sort(students.begin(), students.end(), [](const Student a, const Student b) { return a.id b.id; }); students.erase(std::unique(students.begin(), students.end()), students.end()); }这里的思路是先用sort把相同元素排到一起再用unique把相邻重复的元素移到尾部最后erase删除。unique判断相等的依据是operator所以要么写全局的operator要么给类写一个 friend 函数。堆上分配的对象数组去重逻辑相同但操作时要格外小心别把指针直接放进容器后又想delete[]一旦去重把指针移走了释放就会出二重释放问题。6.4 VSCode 配置 C/C 环境时与类相关的环境坑网上搜vscode c/c环境的人特别多。如果你只用 VSCode 写类、编译类相关的小项目推荐装 C/C 扩展后直接配三份文件tasks.json负责编译launch.json负责调试c_cpp_properties.json负责 IntelliSense 的头文件路径。我踩过最典型的一个坑是代码里使用 C11 特性但 tasks.json 里编译参数没加-stdc11导致编译器报 raw string literal 需要 -stdc11 这类错误而 IntelliSense 却认为没有错误。原因是编辑器里的语法分析和实际编译是两套独立子系统的参数。解决办法是在 tasks.json 的-stdc11里显式指定版本并且和c_cpp_properties.json里的cppStandard保持一致。配置完之后类里面的std::unique_ptr、lambda 表达式、右值引用才能正常编译。microsoft visual c 2015-2022 redistributable 这类运行库的问题我在 Windows 上也碰过。用 MinGW 编译的程序不需要 VC 运行库但如果你用 Visual Studio 编译并拷到没有安装运行库的机器上跑就会缺vcruntime140.dll程序直接弹窗启动失败。这不是代码问题是部署环境问题。排查思路很简单先确认编译工具链再用dumpbin /dependents或 Process Explorer 查看依赖缺失。关于运行时库还有一个细节。如果你在 Release 模式下把/MT和/MD混用了类里有std::string或std::vector这种跨 DLL 边界传递的对象很容易出现不同的堆管理崩溃点经常在析构或delete时。这里就不展开具体汇编了但经验是同一进程内所有模块尽量用同一套运行时库开关避免为省一点内存把这个打开那个关掉。6.5 关于对象是空的吗与判断对象为空的误解这个热词出现频率很高。C 里对象本身占用的内存大小在编译期就确定了最小的空类大小也是 1 字节为了确保两个对象地址不同。所谓判断对象为空常见的是判断指针是否为空Item* p new Item(); if (p ! nullptr) { p-print(); }但要注意这不代表对象内容为空。类里有一个空的std::string和一个 vector 大小为零对象字节数依然非零因为内部还有指针和长度信息。真正判断对象逻辑上是否为空应该用成员函数比如std::string::empty()而不是拿sizeof或者地址来猜。我见过有同学写if (sizeof(obj) 0)想判断对象为空这在 C 中永远不会成立属于把其他语言的概念硬搬过来的典型错误。还有一类问题是对象数组最后两个元素不加伪类。这其实不是 C 问题而是前端 CSS 的div布局相关但热词里把它和我刚才说的对象揉在一起了。C 里处理数组最后 N 个元素有标准思路用size()取长度再做索引比较。比如想跳过最后两个元素写for (size_t i 0; i 2 vec.size(); i)。核心是不要硬编码vec.size() - 2因为如果 size 小于 2结果会被当作超大无符号数循环体直接不执行这是无符号整型减法最容易爆的雷。实际操作中的一点体会类和对象这前半部分我最大的体会是如果你能把对象的生命周期和const 的可变性语义这两件事真正理清楚后面学继承、多态、模板会顺畅很多。很多老手写代码时不假思索地加const成员函数、优先用初始化列表、严格区分栈上和堆上对象其实不是强迫症而是这些选择每一次都在规避一类潜在问题。最后分享一个我自己的小习惯每定义一个类我会顺手列出三个问题的答案——这个类的资源由谁管理默认拷贝行为是否安全哪些成员必须用初始化列表如果这三个问题都能在 10 秒内回答清楚类和对象这部分就算是真正吃透了。