ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++内置类型与自定义类型:从内存布局到类型安全的全面对比

C++内置类型与自定义类型:从内存布局到类型安全的全面对比 我先给你讲个我自己真实踩过的坑之前写一个消息分发模块接口签名是void Process(uint64_t id)调用方拿到的业务编号是int我图省事直接传进去头文件里还写着“注意 id 不会为负”。结果测试环境里出现了一个怎么都复现不了的问题——某条消息的 id 变成了 18446744073709551615查了半天才发现是int被隐式转换成了uint64_t负数直接翻成了天文数字。这个 bug 最后不是逻辑问题而是 C 类型系统的一次“常规操作”。C 里的类型系统平时没人会专门背它但它无时无刻不在决定你的代码怎么存、怎么算、怎么转、怎么崩。很多人分不清内置类型和自定义类型的边界总觉得“int 就是类型class 也是类型有什么好比的”。可真放在一个项目里这两者的设计目标、生命周期、拷贝语义、类型安全规则完全不同。这篇文章我就从底层到实战把 C 内置类型与自定义类型的对比完整梳理一遍适合刚入门的同学建立体系也适合已经写了几年 C 但想系统整理一遍类型系统知识的人。1. 内置类型全景C 给每个程序员发的“基础零件包”1.1 内置类型的底层视角CPU 看什么、编译器看什么内置类型built-in type标准里也叫 fundamental type是 C 语言本身规定好的类型。你不需要 include 任何头文件就能直接用int、char、double因为这些名字是编译器原生认识的。从机器视角看内置类型直接对应当前 CPU 能处理的寄存器和内存宽度。int最常见的实现是 32 位正好对应通用寄存器double对应 IEEE 754 双精度浮点格式。编译器看到int x时做的事情非常直接在栈上分配 4 个字节后续对这个变量的读写就是按“一个有符号 32 位整数”的方式去解释这 4 个字节。这就是内置类型最核心的特点它和硬件是一一对应的几乎没有中间层。这个特点带来了无可比拟的执行效率也让内置类型成为 C “零成本抽象”的底层基石。1.2 完整分类与标准保证的大小内置类型其实比大多数人印象中的要多。标准里的分类大概是这样的空类型void布尔型bool字符型char、signed char、unsigned char、wchar_t、char16_t、char32_t、char8_tC20整型short、int、long、long long以及各自的无符号版本unsigned short等浮点型float、double、long double还有一个容易被忽略的std::nullptr_t它在cstddef里定义专用于空指针常量nullptr属于和指针强相关的类型虽然名字带着std::但本质是语言内置的类型之一。我经常看到有人背“int 占 4 字节、long 占 8 字节”这种口诀但 C 标准实际上给出的保证只是最小值类型标准保证常见 64 位 Linux 平台char恰好 1 字节sizeof(char) 11 字节short至少 16 位2 字节int至少 16 位一般认为是 32 位4 字节long至少 32 位8 字节Windows 上是 4 字节long long至少 64 位8 字节float至少能表示 IEEE 754 单精度4 字节double至少能表示 IEEE 754 双精度8 字节这个“标准只保证最小、平台决定实际大小”的特性是跨平台 bug 的头号来源。long在 Linux 和 Windows 上大小不一样如果你用long去定义文件偏移量或者协议里的字段换个平台就翻车。现代 C 项目里更推荐直接用cstdint里的int32_t、uint64_t后面第 5 章我会专门展开。1.3 cv 限定符和 void 的定位const和volatile这两个限定符虽然不是独立类型但它们和内置类型的关系非常紧密。const int表达的是“初始化之后不能再修改”。注意它的生命周期里也遵循内置类型的规则只是编译器会额外做检查。volatile则是告诉编译器“这个变量的值可能在没有代码修改它的前提下发生变化不要优化掉对它的访问。”典型场景是嵌入式开发里读取某个内存映射寄存器或者多线程环境中共享的某个标志位。不过跨线程同步这里我更推荐直接用std::atomicvolatile并不能保证原子性两者解决的问题不同。void比较特殊你无法定义一个void变量说明它是一种“无”的类型。它主要出现在三个位置函数返回值表示不返回任何东西、函数参数列表表示无参数、以及void*指针表示“指向未知类型的指针”。1.4 一个最容易忽略的基本事实内置类型没有“默认值”这里必须强调一个很多新手甚至一些老手都会踩的坑局部定义的内置类型变量如果不初始化其值是未定义的。int x; std::cout x; // 不报错但输出什么完全不确定全局变量和静态变量会做零初始化但栈上的局部变量不会。new int得到的堆内存同样是未初始化只有写成new int()或new int{}才会零初始化。这个“随缘”的特性恰恰是内置类型和自定义类型最原始的分水岭自定义类型能在构造函数里做强制初始化而内置类型从语言层面就不保证这件事。所以在现代 C 里凡是能显式初始化内置类型的地方我都建议用int x{};这种写法至少它是零值不是随机值。2. 自定义类型全景从 struct 到 enum class 的类型延伸2.1 自定义类型到底“自定义”了什么自定义类型user-defined type字面意思是“用户自己定义的类型”但实际上它真正做的是两件事把若干内置类型组合在一起并给这个组合体附加行为约束。一个最基本的自定义类型就是一个聚合体struct Point { double x; double y; };从内存布局上看Point就是两个连续的double可能有 padding后面讲。但从语义上看Point不再是“两个double数字”而是一个“点”的概念。编译器会禁止你直接拿Point去和一个double做算术运算因为它们的类型不同不能隐式混用——这就是自定义类型的第一个价值类型即约束。2.2 class 与 struct99% 相同的两个关键字C 里class和struct的关键区别只有一个默认访问权限不同。struct默认publicclass默认private。struct S { int x; // 默认 public }; class C { int x; // 默认 private };这个差异听起来小但影响很大。我刚工作的时候见过一个项目里用struct定义接口类所有成员默认公开结果不小心把内部实现细节暴露了想收回去的时候一堆外部代码都依赖着它。现在我自己的习惯是纯数据聚合用struct有行为、有封装、有内部状态的类型用class。这样别人看代码时单看关键字就能知道这个类型大概的定位。访问控制其实是类型系统里极其重要的一层它限制了谁可以碰内部数据把“不应该发生的事”从语言编译层面挡掉了。这个能力是内置类型没有的——你无法限制一个int能被哪些函数读取。2.3 枚举类型从传统 enum 到 enum class 的演进枚举类型的本质是用有名字的整型常量替代魔法数字。C 风格的传统enum有一个著名的坑它会被隐式转换成int。enum Color { Red, Green, Blue }; void Fill(int c); Color c Red; Fill(c); // 合法Color 隐式转换成 int这等于把一个业务语义类型降级成了裸整数任何整型都能冒充Color传进来类型系统形同虚设。C11 引入的enum class修掉了这个问题enum class Color { Red, Green, Blue }; void Fill(int c); Color c Color::Red; Fill(c); // 编译错误不能隐式转换 Fill(static_castint(Color::Red)); // 必须显式转换同时enum class还允许显式指定底层类型比如enum class Status : uint8_t { Ready, Done };在保证内存布局的情况下还能精确控制占用空间。这就是自定义类型在“类型安全”层面做得比内置类型更严格的典型例子。2.4 union能省钱但也要自己负责union也是自定义类型的一种它最特别的地方在于所有成员共享同一块内存区域。也就是说一个union的大小等于它最大成员的大小而不是所有成员之和。union U { int i; float f; }; // sizeof(U) 通常是 4i 和 f 重叠存放这个特性在底层编程里很有用比如解析网络协议时想用不同视图读同一块数据。但我要提醒一点C 标准规定你只能读取 union 中“当前活跃”的那个成员读非活跃成员属于未定义行为。编译器可能会基于这个规则做激进优化导致你拿到完全无法理解的结果。如果只是需要“多选一”的存储我更推荐用std::variant它把活跃成员的管理变成了运行时安全检查错误成本从“悄悄出错”变成了“抛出异常”。2.5 别名 alias类型系统的“翻译官”typedef和using都能给已有类型起别名比如using size_t unsigned long;。注意别名不是新类型它和原类型是完全等价的编译器不会因为别名不同就帮你拦截混用。举个例子using Length double; using Mass double; double L 10; Mass m 5; L m; // 完全合法因为 L 和 m 本质上都是 double这导致了一个常见问题当业务里的“长度”“质量”“时间”都是double时它们可以互相赋值编译器不会提醒你。这种场景就需要真正的自定义类型来包装语义比如用结构体struct Mass { double value; };或者直接用标准库的std::chrono::duration。别名的价值在于简写不在于增加类型检查。3. 本质差异对比内存、生命周期、拷贝语义与类型安全3.1 内存布局为什么结构体看着小sizeof 却很大内置类型的“内存布局”极其简单它本身就是一块数据和它的类型宽度对齐。但自定义类型的成员布局受对齐规则影响很大。看两个结构体struct A { char c; // 偏移 0 int i; // 偏移 4c 后面补了 3 字节 padding short s; // 偏移 8 }; // sizeof(A) 12尾巴还有 2 字节 padding struct B { int i; // 偏移 0 char c; // 偏移 4 short s; // 偏移 6 }; // sizeof(B) 8A 和 B 的成员一模一样只是顺序不同sizeof差了 4 字节。因为编译器要让每个成员都对齐到它的自然边界int通常是 4 字节对齐不得不在成员之间插入 padding。这个知识点在嵌入式、网络协议、文件格式处理时特别重要。假如你把一个协议结构体直接强转到字节数组去发送你发送出去的并不是你觉得的那些字段字段之间可能夹着垃圾 padding。正确做法要么是手动定义紧凑布局#pragma pack不算标准 C能不用尽量不用要么显式做成字节流逐个字段写入。3.2 生命周期内置类型“随缘”自定义类型“掌控”内置类型的生命周期可以用四个字概括没有钩子。作用域进入不为内置类型做任何事情。作用域退出不为内置类型做任何事情。拷贝就是一次字节复制。销毁不释放任何额外资源。所以一个局部int变量离开作用域时编译器最多把它所在的栈空间标记为可复用不会跑任何清理函数。而自定义类型则不同构造函数在对象生命周期开始时被调用析构函数在对象生命周期结束时被调用。这两个函数给了程序员在类型层面“挂钩子”的能力。RAIIResource Acquisition Is Initialization就是建立在这个基础上的。看一个最简单的例子class FileGuard { std::FILE* fp; public: FileGuard(const char* path) { fp std::fopen(path, r); } ~FileGuard() { if (fp) std::fclose(fp); } };函数里只要创建FileGuard离开作用域时文件必定被关闭无论中途走哪个 return 分支还是抛了异常。如果你用裸句柄FILE*就得在每个退出点手动fclose漏一个就是资源泄漏。这背后的核心差异很简单内置类型只管“值”自定义类型还能管“资源”和“状态”。3.3 拷贝语义位拷贝 vs 深拷贝内置类型的拷贝语义简单到没得商量int b a;就是把 4 个字节从 a 复制到 b之后两者毫无关系。自定义类型默认的拷贝也是“逐成员拷贝”表面上很像位拷贝但一旦成员里有指针或动态资源问题就来了class Buffer { char* data; size_t size; public: Buffer(size_t n) : size(n), data(new char[n]) {} ~Buffer() { delete[] data; } }; Buffer a(100); Buffer b a; // 默认拷贝构造data 指针被复制指向同一块堆内存a和b析构时会对同一块data执行两次delete[]程序直接崩。所以 C 里有一条著名的“三/五法则”如果自定义类型需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任意一个通常意味着另外几个也需要一起自定义C11 之后还要考虑移动构造和移动赋值。内置类型从来不用考虑这事但自定义类型必须主动管理。这也是为什么现代 C 里大家拼命推荐“规则零”的方向让类型持有标准库容器比如std::string、std::vector而不是裸指针让容器去处理拷贝和资源释放程序员就省掉了手写那五个函数的工作。3.4 类型安全内置类型宽容自定义类型严格类型安全这个维度上两类类型的差异非常鲜明。内置类型之间的转换极其宽松。int到double是隐式提升double到int是隐式窄化编译器最多给个警告int和无符号unsigned混合运算时int会隐式转成unsigned这经常产生灾难性的结果我开头那个 bug 就是这个规则一手造成的。自定义类型默认是“铁板一块”的。除非你给某个类型定义转换运算符或者给构造函数写上隐式转换能力否则两个自定义类型之间不能互转Point永远不会自动变成double。进一步的C11 之后你可以在构造函数前加explicit禁掉隐式转换class Fraction { public: explicit Fraction(int n) : num(n) {} private: int num; }; void f(Fraction frac); f(3); // 错误explicit 禁止隐式转换 f(Fraction(3)); // 正确必须显式构造同样的自定义类型也可以定义自定义转换运算符struct Byte { int raw; explicit operator int() const { return raw; } }; Byte b{200}; int x b; // 错误explicit 阻止隐式转换 int y int(b); // 正确显式转换你可以把这种差异理解成内置类型用的是“宽松的自动档”写出代码很快但边界全靠人肉把关自定义类型用的是“手动档”会多写一些代码但编译器能帮你把错误挡在编译期。一个成熟项目里我更愿意在跨函数边界的地方多用后者。3.5 运算与行为扩展运算符重载的空间内置类型自带一组运算能力、-、*、/、比较、位运算等等。你没法给int增加一种新的运算规则它是语言写死的。自定义类型则可以重载运算符让自定义类型也能像内置类型一样自然地参与运算。比如一个二维向量类型struct Vec2 { double x, y; }; Vec2 operator(const Vec2 a, const Vec2 b) { return {a.x b.x, a.y b.y}; }这样v1 v2写起来就和内置类型一样顺手。但要特别注意运算符重载不要让行为变得反直觉。operator可以做任意事如果它内部其实是减法那就是在制造灾难。C 社区有条不成文的规矩重载运算符时行为应该和你对这个运算符的直觉一致。还有像operator、operator||这类运算符千万不要重载因为重载后短路求值特性会失效代码容易被误读。4. 实战中的选型与坑内置和自定义怎么配合才不踩雷4.1 选型判断什么时候裸奔什么时候套壳“能用内置类型就用内置类型不能才用自定义类型”——这是一条浪费了不少性能之后我才真正认同的原则。结合我自己写业务代码和底层代码的经验选型时我会依次问自己四个问题这个值在语义上会被其他类型混用吗如果“长度”和“重量”都是double且项目里允许混用就该考虑包装。我需要保证初始化吗需要限制非法值吗比如“密码强度 0~100”这种业务约束裸int拦不住 1000 这种非法输入一个带校验的类可以。这个值需要随作用域释放资源吗比如持有文件、锁、内存那必须是 RAII 自定义类型。这个值的传递频率极高性能敏感吗如果某个对象在热循环里被创建销毁几十万次结构体里的构造函数、析构函数、动态分配会被放大这时候要么用内置类型做底层要么精心设计移动语义。日常经验是80% 的业务场景用内置类型做存储、用自定义类型做语义和约束两者是配合关系不是替代关系。比如std::vectorint内部用指针和长度这些内置类型实现但暴露给用户的是类型安全的容器接口std::chrono::seconds内部就是一个整数但外部语义是“秒”而不是“任意整数”。4.2 内置类型常见坑整型提升、无符号数比较、窄化转换吃透内置类型的人能把很多隐蔽 bug 消灭在 code review 阶段。整理几个高频坑。无符号数与有符号数混合比较std::vectorint v; for (size_t i 0; i 10; i) v.push_back(i); size_t n v.size(); int pos -1; if (pos n) { // pos 被隐式转换为 size_t变成一个巨大的正数 // 这个分支不会被执行 }规则是有符号和无符号混合运算时有符号数会被转换成无符号数。所以-1 n实际是SIZE_MAX n通常为假。凡是在需要比较长度的地方别用裸int存负值或者干脆把条件改写成n 0这类避免符号混算的形式。整型提升规则所有小于int的类型char、short等参与算术运算时会先提升为int。这个规则本身没什么问题但结合“char是否有符号是实现定义的”这一事实就很容易踩坑。比如用一个char存字节流里的 0xFF在char是有符号的平台char c 0xFF; c 255是假因为char提升成int后是-1。处理字节数据我的建议只有一条永远用unsigned char别用裸char。窄化转换用花括号初始化时C11 起窄化转换会直接报错int a{3.14}; // 编译错误double 到 int 是窄化转换 int b 3.14; // 编译通过值变成 3建议在代码里尽量使用花括号初始化让编译器帮你在“可能丢失精度”的地方把关。有符号溢出有符号整数的溢出是未定义行为无符号整数溢出是定义行为回绕。写prefix sum、快速幂这类算法时务必取模或者用uint64_t否则编译器可能在优化时基于“不会溢出”的假设做出你想象不到的改动。“它在我机器上没问题”这种话在有符号溢出面前是不成立的。4.3 自定义类型常见坑三/五法则与默认构造自定义类型给了你掌控权也将三组责任压到你肩上。坑一没有默认构造函数导致调用方编译失败。如果你定义了带参数的构造函数但没定义无参构造函数那么T t;就无法编译。要么显式加默认构造要么确保调用方都走带参构造路径。坑二默认拷贝在大对象上的性能劣化。自定义类型如果老老实实写了一个深拷贝那么每次按值传递对象都是一次完整拷贝。C11 之后引入移动语义把临时对象的转移成本降到了接近零但前提是你实现了移动构造函数或者在类里用了std::unique_ptr这类本身就是移动语义的成员。我见过不少老项目类里什么都没有却一直被按值传来传去性能分析一抓一大把拷贝开销。坑三析构里抛异常。析构函数默认是noexcept的如果析构里抛异常程序会直接std::terminate。所以析构函数只做清理不要做可能抛异常的事务操作。这是个听起来很基础、但真出问题时特别难查的坑。4.4 混合使用的最佳实践类型复合、强类型包装、RAII实战中最有价值的做法是用自定义类型把内置类型“语义化”而不是彻底绕过内置类型。先说强类型包装。假设你要给“秒”和“毫秒”建类型struct Seconds { long long value; }; struct Milliseconds { long long value; }; void SleepFor(Seconds s); Seconds s{3}; SleepFor(s); // 正确 SleepFor(Milliseconds{3}); // 编译错误类型不匹配这把时间单位写进了类型里错误从运行时提前到了编译期。实际项目里我经常见到的替代方案是直接用一个std::chrono的durationdouble因为它本身就是“数值 单位”的强类型组合比手写 struct 还要成熟。再说RAII 包装底层资源。比如你要封装一个数据库连接内部用裸void*或者句柄存储底层连接但对外提供的是一套类接口构造时连接、析构时释放。这样任何一份数据库连接的代码都不会逃过生命周期管理。标准库里的std::lock_guard、std::unique_ptr、std::ifstream全是这个思路的产物。最后说组合而不是继承。自定义类型更推荐用成员对象的方式组合在一起而不是为了复用代码去搞深层继承。深层继承会带来“切片”问题把子类对象按值传给基类参数时子类特有的部分丢失和复杂的对象模型日常业务代码里大部分情况都能用组合解决。5. 现代 C 对类型系统的加强auto、强枚举与类型安全5.1 auto 与 decltype 的推导行为很多人觉得auto就是“偷懒”实际上auto是 C 类型系统的一部分它的推导规则沿用了模板参数推导的规则并不是简单的“拿右边表达式类型”。const int ci 42; auto a ci; // a 是 intconst 被剥掉了 auto b ci; // b 是 const int引用不会自动剥掉 const decltype(ci) c 3; // c 是 const int decltype((ci)) d ci; // d 是 const int(ci) 是左值表达式这里最大的坑是auto会丢弃引用和顶层 const如果你希望“原样保留”要么显式写const auto要么用decltype(auto)推导函数返回值。写模板函数、泛型代码时这个细节决定了一次编译报错和你加班查半天两件事的区别。5.2 enum class 与显式转换传统enum在类型安全上开了一个很大的口子它隐式转int导致每个枚举名都能当整数用。enum class把口子补上了代价是你必须写显式转换。我的经验是在项目里全面用enum class替换传统enum哪怕要多写几行static_cast这些显式转换等于在代码里留下了“我明确知道要转成整数”的标记比隐式转换安全得多。还有一个容易被忽略的点如果你需要把一个enum class转成整数尽量把转换逻辑收敛到一个函数里而不是散落在各个调用点否则以后修改枚举值时你会被迫在一堆static_castint里找自己用过哪些枚举。5.3cstdint与平台无关的固定宽度整数前面说过int、long的尺寸是平台相关的。跨平台代码的正确姿势是使用固定宽度整型uint8_t、int32_t、uint64_t这类类型在cstdint中定义宽度和位数的含义明确。解析二进制文件格式、网络协议的字段时用它们定义字段宽度一次定义多平台通用。但也要注意两个细节。一是int8_t本质上可能是signed char在流操作里会被当成字符打印而不是数字二是这些类型不是“总是存在”只有当平台实际支持对应宽度时才定义虽然绝大多数桌面和嵌入式平台都支持但写极致的可移植代码还是要留一手。5.4 通过自定义类型把语义装进类型里现代 C 的思考方式里类型本身就是文档。一段代码读完你应当能从函数签名里看出这个函数要什么、能给什么。举个例子标准库的std::optionalT表达的语义是“可能有也可能没有”std::variantA, B表达的是“要么是 A要么是 B”std::string_view表达的是“对某段字符串的只读视图不拥有它”。这些全都是自定义类型但它们把“约束”和“意图”直接放进了类型签名里。自己设计类型时也适用这个思路如果一个函数需要传“时间间隔”和“内存大小”不要在参数上写两个double写Duration d和MemorySize m。这样调用顺序错了、单位写反了编译器都会在编译期替你拦住。我在实际项目里的体会是在函数签名这个层面多花一点心思设计类型收益是整个项目周期里肉眼可见的尤其当团队扩大、接口被多个模块复用时类型系统就是你的第一道防线。最后分享一个我自己坚持了很多年的小习惯项目里跨越模块边界的函数参数凡是一眼能看出业务语义的裸int或double我都会逐步替换成带语义的自定义类型或标准库类型。一开始确实会被同事吐槽“写起来麻烦”但跑几年下来回头看真正难查的线上 bug有一大半都是这种人肉语义在传递过程中被混用导致的。类型系统不会替你消除所有 bug但好的类型设计可以把一批 bug 从运行期挪到编译期这比任何测试工具都来得早、来得便宜。
返回列表