前言


「C++ 有多少种数据类型」是个看起来很简单的问题,但真正写代码时出错的,往往不是不知道有 int 和 double,而是对它们的保证范围和平台差异有错误的直觉。比如认为 int 一定是 4 字节、char 一定是无符号、long 一定比 int 大、两个 double 用 == 比较没问题。这些直觉在某个平台上碰巧成立,换一个编译器或换一个操作系统就会翻车。


标准对基本类型只规定最小范围,不规定字节数。sizeof(int) 可以是 2、4 或 8,只要它能表示至少 16 位;char 是否有符号由实现决定;long 在 Windows 的 LLP64 模型下是 4 字节,在 Linux 的 LP64 模型下是 8 字节。这些差异不是冷知识,它们直接影响序列化格式、二进制协议和跨平台接口。


本文从「标准保证什么、实现决定什么」出发,依次讲基本类型的范围、整型转换与溢出、浮点比较、定宽类型与类型推导,最后给出可读的检查代码。全文以 C++17 为基准,涉及 C++20 的变化会单独标注。文中结论均按标准语义描述,本机没有编译环境,未做运行验证。


一、标准只保证最小范围


C++ 的基本类型分为几组:布尔 bool、字符类型(char、signed char、unsigned char、wchar_t、C++11 的 char16_t/char32_t、C++20 的 char8_t)、整数类型(short、int、long、long long 及各自的 unsigned 版本)、浮点类型(float、double、long double)以及 void。


标准给出的保证可以归纳为下表。注意「最小位数」一列,这是标准的下限,不是实际大小:




类型标准保证的最小范围常见实现(LP64 / LLP64)



char至少 8 位,恰好 1 字节1 字节,符号由实现决定

short至少 16 位2 字节

int至少 16 位4 字节

long至少 32 位Linux 8 字节 / Windows 4 字节

long long至少 64 位8 字节

float至少 6 位有效数字4 字节,通常 IEEE 754 单精度

double至少 10 位有效数字8 字节,通常 IEEE 754 双精度

wchar_t能表示实现支持的最大字符集Windows 2 字节 / Linux 4 字节



sizeof(char) 恒为 1,这是标准的定义方式:一个字节就是一个 char 的大小,而一个字节有多少位由 CHAR_BIT 给出,标准要求至少 8 位。绝大多数平台是 8 位,但不能假设所有平台都如此。


char、signed char、unsigned char 是三种不同的类型。char 的符号性是实现定义行为:x86 上的 GCC 和 MSVC 默认有符号,ARM 上的 GCC 默认无符号,可以用 -funsigned-char / -fsigned-char 改变。因此把 char 直接传给 std::tolower 或用作数组下标时,负值会带来未定义行为;正确做法是先转成 unsigned char。


浮点类型也有类似的坑。标准并不强制要求 IEEE 754,只是大多数实现遵循它;std::numeric_limits<double>::is_iec559 可以用来在编译期判断。即使遵循 IEEE 754,0.1 + 0.2 == 0.3 也不成立,因为这三个数都无法用二进制精确表示。


二、整型转换:提升、截断与符号


理解整型运算,要记住三条规则。


第一条是整型提升(integral promotion)。 比 int 小的类型(char、short、bool 等)参与算术运算前会先提升为 int(如果 int 能表示其全部值),否则提升为 unsigned int。所以 char a = 200; char b = 100; a + b 的结果类型是 int,值为 300,不会在 8 位处回绕。这也解释了一个常见疑问:为什么两个 unsigned char 相加不会溢出。


第二条是寻常算术转换(usual arithmetic conversions)。 当二元运算符两侧类型不同时,会转换到能容纳两者的公共类型。危险就出在有符号与无符号混用:如果无符号类型的秩不低于有符号类型,有符号操作数会被转换成无符号。int a = -1; unsigned b = 1; a < b 的结果是 false,因为 -1 被转换成了一个极大的无符号数。这类比较在开启 -Wsign-compare 时会有警告,不要忽略。


第三条是溢出规则不同。 无符号整数的运算按模 2^N 回绕,这是标准明确规定的;有符号整数溢出是未定义行为(undefined behavior, UB),编译器可以假设它不会发生,从而做出在溢出时看似荒谬的优化。不要写 if (x + 1 < x) 来判断溢出,这个条件在优化后可能被直接删除。需要检查时用宽类型计算,或用编译器内建函数(如 GCC 的 __builtin_add_overflow,属扩展)。


C++20 起,有符号整数被要求采用二补码表示,但有符号溢出仍然是 UB,这一点没有改变。


三、浮点数与字面量


浮点数的比较要分场景。判断两个计算结果的「相等」,通常用相对误差或绝对误差;判断某个值是否恰好等于常量(例如哨兵值、解析结果),直接用 == 是合理的。把所有浮点比较都换成 epsilon 比较,反而会让「两个不同的值」被判为相等。


字面量的后缀决定类型:1 是 int,1U 是 unsigned int,1L 是 long,1LL 是 long long,1.0 是 double,1.0f 是 float,1.0L 是 long double。C++14 起可以用单引号分隔数字(1'000'000),也支持二进制字面量(0b1010)。


// 适用于 C++17
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <limits>
#include <type_traits>

template <typename T>
void show_limits(const char* name) {
std::cout << std::left << std::setw(12) << name
<< " sizeof=" << sizeof(T)
<< " bits=" << std::numeric_limits<T>::digits
<< " signed=" << std::numeric_limits<T>::is_signed
<< " min=" << static_cast<long long>(std::numeric_limits<T>::min())
<< " max=" << static_cast<long long>(std::numeric_limits<T>::max())
<< '\n';
}

int main() {
show_limits<signed char>("signed char");
show_limits<short>("short");
show_limits<int>("int");
show_limits<unsigned>("unsigned");

std::cout << "long sizeof=" << sizeof(long) << '\n';
std::cout << "long long sizeof=" << sizeof(long long) << '\n';
std::cout << "pointer sizeof=" << sizeof(void*) << '\n';
std::cout << "wchar_t sizeof=" << sizeof(wchar_t) << '\n';
std::cout << "size_t sizeof=" << sizeof(std::size_t) << '\n';

// 判断浮点是否遵循 IEEE 754
std::cout << "double is_iec559=" << std::numeric_limits<double>::is_iec559 << '\n';

// 有符号与无符号混用的经典陷阱
int negative = -1;
unsigned positive = 1;
std::cout << "(-1 < 1u) = " << std::boolalpha << (negative < positive) << '\n';
return 0;
}

把 char 安全地传给字符分类函数,写法是两步转换:


// 适用于 C++17
#include <cctype>
#include <string>

std::string to_upper_ascii(const std::string& input) {
std::string out = input;
for (char& ch : out) {
unsigned char uch = static_cast<unsigned char>(ch);
if (uch >= 'a' && uch <= 'z') {
ch = static_cast<char>(uch - 'a' + 'A');
}
}
return out;
}

这里没有调用 std::toupper,是因为 std::toupper 的行为依赖当前的 C 区域设置(locale),默认的 "C" 区域只保证处理 ASCII;同时它的参数必须能表示成 unsigned char 或等于 EOF,直接传负的 char 是 UB。手写 ASCII 判断没有这些问题,也便于理解边界。


四、定宽类型、size_t 与推导


需要精确位宽时,用 <cstdint> 里的 std::int8_t、std::int32_t、std::uint64_t 等。注意这些类型是可选的:只有在平台恰好存在 8/16/32/64 位且无填充位的整数类型时才会定义。绝大多数平台都有,但标准不保证。需要「至少 N 位」时用 std::int_least32_t 或 std::int_fast32_t。


表示大小和下标用 std::size_t(<cstddef>),表示指针差值用 std::ptrdiff_t。sizeof 的结果类型就是 std::size_t,它是无符号的,所以 for (int i = 0; i < v.size(); ++i) 会有符号比较警告,容器为空时把 size() - 1 赋给有符号数还会得到巨大的值。


auto 推导会丢弃顶层 const 和引用,decltype(auto) 则保留。这两者的差异在返回引用时特别重要:


// 适用于 C++17
#include <string>
#include <vector>

std::string& first_name(std::vector<std::string>& names) {
return names.at(0); // 越界时抛 std::out_of_range
}

auto copy_of_first(std::vector<std::string>& names) {
return first_name(names); // auto 推导为 std::string,发生拷贝
}

decltype(auto) ref_of_first(std::vector<std::string>& names) {
return first_name(names); // 推导为 std::string&,不拷贝
}

auto 按值推导,返回类型是 std::string,函数返回时拷贝一次;decltype(auto) 保留表达式的引用性。如果误写成 auto& 而函数返回的是局部变量,就会产生悬垂引用。C++17 的结构化绑定(structured binding)也遵循类似规则,auto [a, b] = pair; 是拷贝,auto& [a, b] = pair; 才是引用。


常见坑点


坑点 1:假设 int 是 4 字节。


❌ 直接把 int 写入二进制文件或网络包。


✅ 用 std::int32_t 显式指定宽度,并统一字节序;或者用文本格式。


坑点 2:有符号与无符号直接比较。


❌ for (int i = 0; i < vec.size(); ++i),空容器时 size() 参与比较会出现符号转换。


✅ 用 std::size_t i = 0,或使用范围 for;比较前确认两边符号一致。


坑点 3:依赖有符号溢出回绕。


❌ if (x + 1 < x) { /* 认为发生了溢出 */ }。


✅ 有符号溢出是 UB,判断要用宽类型或编译器内建函数。


坑点 4:把负的 char 传给 std::toupper / std::tolower。


❌ std::toupper(ch),当 ch 为负值时是 UB。


✅ 先 static_cast<unsigned char>(ch) 再传;或像示例那样只处理 ASCII 范围。


坑点 5:用 == 比较浮点计算结果。


❌ if (a + b == 0.3)。


✅ 用相对误差比较,或改用整数/定点表示金额等需要精确的场景。


坑点 6:以为 long 一定等于指针宽度。


❌ 在 Windows 上把 long 当指针大小的整数使用。


✅ 用 std::intptr_t / std::uintptr_t 表示能容纳指针的整数。


坑点 7:auto 意外拷贝。


❌ for (auto s : vec_of_strings),每个元素拷贝一次。


✅ 只读用 const auto&,需要修改用 auto&,确实要副本才用 auto。


坑点 8:窄化初始化被忽略。


❌ char c = 300; 静默截断。


✅ 用列表初始化 char c{300};,编译器会报窄化错误;确实要转换时写 static_cast 并确认范围。


总结




主题标准规定实践建议



类型大小只保证最小范围跨平台数据用定宽类型

char 符号实现定义转 unsigned char 再参与字符分类

无符号溢出按模 2^N 回绕可用于哈希等场景,但要写清意图

有符号溢出未定义行为不做任何依赖,用宽类型检查

浮点表示不强制 IEEE 754用 is_iec559 判断,比较用容差

指针大小实现定义用 std::uintptr_t,不要用 long



数据类型的关键不是记住一张字节数表,而是分清「标准保证的」「实现决定的」「未定义的」三层。凡是跨越编译单元、跨越平台、跨越进程边界的数据,都应该用定宽类型并显式处理字节序;凡是涉及符号混合的表达式,都应该让两边的类型先对齐。把这两条落实到代码里,大部分「换个平台就出错」的问题会在写的时候就被挡住。




Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐