首页 C++ From Scratch: 0.1. 类型系统
sentomk's thoughts

C++ From Scratch: 0.1. 类型系统

类型的概念在 C++ 中非常重要,它不仅是存储布局的标识符,更是开发者与编译器及团队成员之间的契约,用以明确“此对象意在何种用途”与“具备何种能力”。每一个变量、函数形参与返回值都必须拥有静态类型,编译器据此完成类型检查、重载决议、模板推导与各类优化。

但是,类型既能成就优雅的表达,也可能成为误用和混乱的源头。为此,我们在选则类型时应遵循一组明确的规则:

  • 语义先行:先明确业务或逻辑层面对象的角色,再选用最能直观映射该角色的类型;
  • 最小权限:仅赋予对象或函数完成其职责所需的最小能力,避免额外开放不必要的操作;
  • 值语义优先:优先使用可按值拷贝或移动的类型,以简化生命周期管理并提升测试可靠性;
  • 界限清晰:计数与索引使用合适宽度的无符号类型;差值运算使用有符号类型;协议与存储需固定宽度整数;
  • 显式可空:只有在业务模型确实允许“无对象”状态时,才使用可空类型(如裸指针或 std::optional),否则采用不可空引用或视图;
  • 可移植可度量:当性能与可移植性至关重要时,明确指定整数宽度、数据对齐与字节序,并优先使用标准库类型以保证一致行为。

在本节中,我对 C++ 类型系统的主要功能进行了非正式的概述,我们先从最基本的标量类型说起,再逐步过渡到复合类型和可调用类型。每一小节都将按照定义、选型原则、示例的结构展开。

1. 标量类型 (Scalar Types)

定义
能直接持有一个值、可按值拷贝/比较/赋值的一类类型集合。包含:

  • 算数类型 (arithmetic);
  • 枚举类型 (enumeration);
  • 指针类型 (pointer);
  • 成员指针 (member pointer);
  • 空指针类型

1.1 算术类型 (Arithmetic Types)

1.1.1 整型 (Integral)
C++ 沿襲 C 语言传统,将所有用于表示离散整数的类型归为整型。包括:

  • 字符类型charsigned charunsigned char

    • 虽然 char 常用于存放文本字符,但其本质是一个小范围整数(通常 8 位),支持算术运算。
  • 布尔类型bool

    • 只有 true/false 两值,但底层常以 0/1 存储
  • 常规整型shortintlonglong long(及其无符号变体)

  • 固定宽度整型<cstdint> 提供):std::int8_tstd::uint16_tstd::int32_tstd::uint64_t 等。保证在所有平台上具有相同位宽和符号属性。一般用于制定跨平台二进制协议、文件格式,可确保数据布局可预测。

  • 特殊整型std::size_t

    • std::size_t 最早在 C89 的 <stddef.h>(C++ 中为 <cstddef>)中作为“对象大小类型”(object size type)引入,用于表示任意对象或数组的字节长度;在 C99 中被正式定义为无符号整数,C++ 沿用其语义。其底层类型因平台而异:在 32 位系统上通常对应 unsigned int,而在 64 位系统上则可能是 unsigned longunsigned long long。由于以下几点特性,我推荐优先使用 std::size_t 作为计数和下标类型:
      • 覆盖范围:它能表示平台上可分配的最大对象大小,不会因索引超过范围而发生溢出
      • 标准一致性:标准库容器(如 std::vector::size())即返回 std::size_t,使用相同类型可以避免隐式转换
char c = 'A';           // 实则为整数 65 (ASCII)
signed char sc = -1;    // 通常范围 -128...127
unsigned char uc = 255; // 范围 0...255

bool flag = true;       // 底层与整型兼容

int i = 42;
int L = 1'000'000L;

// 使用固定宽度整型的场景
std::uint32_t packetSize = 1024;  
std::int64_t  offset     = -4096;

// 使用 std::size_t 的场景
std::vector<int> v{1,2,3};
for (std::size_t i = 0; i < v.size(); ++i) {
    // ...  
}

1.1.2 浮点型 (Floating)
用于近似表示实数,一般用在科学计算、图形坐标、物理模拟等需小数运算的场景。包括:

  • float 通常 32 位
  • double 通常 64 位
  • long double
float PI = 3.14f;     // 单精度
double x = 2.718;     // 双精度
long double y = 1.0L; // 扩展精度

1.2 枚举类型(Enumeration)

  • 定义:将一组相关常量赋予命名,使代码更具自解释性。
    • enum:传统枚举,底层类型由实现决定;
    • enum class:强类型枚举,可指定底层类型;
enum Color { Red, Green, Blue };
enum class Direction : uint8_t { North, South, East, West }; 

1.3 指针 (Pointer)

指针是 C++ 的核心概念之一,它直接映射到内存地址,为语言提供了对底层资源的精细控制能力。通过指针,我们既能高效地遍历与操作动态分配的内存,也能实现复杂的数据结构(如链表、树、图等)的灵活连接。与此同时,指针的使用也暗含所有权与生命周期管理的挑战:裸指针(T*)虽零开销却不具资源回收语义,智能指针(std::unique_ptrstd::shared_ptr 等)则通过 RAII 模式自动管理内存,而成员指针(T C::*)与空指针类型(std::nullptr_t)则分别承载对类内部成员的访问和对空值的类型安全支持。接下来的部分我将依次探讨这些指针形态的定义、语义、使用场景及最佳实践。

1.3.1 裸指针与智能指针

裸指针 (T*)

  • 定义:直接存储对象地址,可为 nullptr
  • 语义
    • 非拥有借用 (non-owning borrow):临时访问已有对象,函数不负责销毁;
    • 可空拥有 (owning, nullable):不推荐;若用于所有权,必须手动 delete,极容易出错。
  • 风险
    • 空指针访问导致未定义行为;
    • 悬垂 (Dangling) 指针易引发崩溃;
    • 无法在类型层面表达释放责任,容易遗漏或重复释放。
// 非拥有借用:调用者负责生命周期
void process(Resource *r)
{
	if (r) // 必须检查
		r->use();
}

若确实需要可空借用,请在接口文档或类型签名中注明;若需要管理资源所有权,请优先使用智能指针

1.3.2 成员指针

1.3.3 空指针类型

1.4 数字字面量与字节语义

  • 数字字面量分隔
    • C++14 起支持单引号作为千位分隔符,增强可读性:
auto million = 1'000'000u;
  • std::byte
    • 定义于 C++17,仅作原始字节标签,不具算术运算。
    • 若需要当作整数使用,需显式转换:
std::byte b{0xFF};
int x = std::to_integer<int>(b);

2. 复合类型 (compound types)

定义
由其它类型”复合“而成,或不直接是一个可持值的标量。包含:

  • 数组类型 ( T[N]、T[] );
  • 函数类型 ( R(Args... ),函数不是对象);
  • 引用类型 ( T&T&& );
  • 类类型 ( class/struct,包括所有用户自定义类型);
  • 联合类型 ( union )

按照语义职责划分,复合类型又可以被划为三类:

  1. 承载/拥有 (own):以对象语义持有并管理状态或资源(std::vectorstd::array、自定义 class/structunion/variant);
  2. 借用 (borrow):以非拥有语义对现有对象进行别名或切片访问(T&/const T&std::spanstd::string_view、指针 T*);
  3. 调用 (call):以可调用语义描述行为的类型或包装(函数类型 R(Args...)、函数指针、std::function、lambda 的闭包类型)。

理解处理复合类型时的选型原则,可以帮助我们用类型表达语义将错误前置到编译期在成本最低、反馈最快的阶段阻止将来最昂贵的错误,是工程实践的首要目标之一。

一、数组

C/C++ 语言中内置了一种数组类型,即写成 T[N]/T[]定长、连续内存的序列,我们称之为内建数组。内建数组本身并不难用,甚至在一些必须保持 C ABI 或需要精确布局的场景下是第一选择。但是在大多数表达式里内建数组会自动衰变T* 指向首元素,这是 C 时代为了便于指针算数与兼容函数调用的设计,会导致关键语义被丢在了约定里而不是类型里,性能大打折扣。
现代 C++ 通过 std::array/std::span长度与借用语义带回类型系统,这两种可见于类型的语义正是为了把最容易、最昂贵的错误(如越界、悬垂、无谓拷贝)前置到编译期

原则

  1. 对于定长序列(即编译期已知):std::array<T, N>
    • 具有值语义,且可与算法/Ranges 相互操作;
std::array<int, 3> a {1, 2, 3};
auto b = a; // 可直接拷贝
  1. 对于变长序列(运行期):std::vector<T>
    • 自动管理内存,使用 reverse 可控制其增长。返回值传递具备移动语义;
std::vector<float> v;
v.reserve(100);
v.push_back(3.14f);
  1. 对于连续切片借用(不拥有):std::span<T>/std::span<const T>
    • 类型中带有长度,不用再额外传递 size_t,更安全,可读性也更高;
void process(std::span<const int> xs);
// 调用:process({arr, arr_size});
  1. API 中不要写 T a[]T* p, size_t n,否则数组一到函数就会衰变成指针从而丢失长度信息
// ❌
void f(int *p, std::size_t n);
// ✅
void f(std::span<int> xs);
  1. 对只读文本使用 std::string_view
void log(std::string_view sv);

建议

  1. 不要对非平凡类型使用 memcpy/memset,对于 std::string 或自定义对象,一旦 memcpy 就会导致未定义行为。推荐使用以下写法:
std::ranges::copy(src, dest.begin());
  1. 多维数组优先考虑扁平+步长。vector<vector<int>> 的写法会分散内存,影响缓存。推荐写法:
std::vector<T> buf(rows * cols);
auto at = [&](int r, int c){ return buf[r * cols + c]; };

二、函数
函数类型描述可用 R(Args...)。函数本身不是对象,但是

本文由作者按照 CC BY 4.0 进行授权