C++Adventures:Types

程序员咋不秃头 2025-04-02 02:05:41

这个系列将从新的视角来回顾 C++ 语言中的基本概念,每篇内容的难度等级定位为三到五星,乃温故知新之作。

本篇回顾的概念是 Types,即 C++ 的类型。

首先来看 Type 这个词的定义:

a or group of people or things that share particular qualities or features and are part of a larger group

具有特定品质或特征并属于更大群体的一类或一组人或事物

由此可知,Types 是抽象出来的人或事物的一些共性,这些共性是有抽象等级的,高抽象等级的事物可以包含低抽象等级的事物。例如,按性别可以将人分类为男人和女人,而男人和女人又属于更高一级的分类——人,人又属于再高一级的分类——动物……抽象没有尽头。

编程中所抽象的事物即是数值,所以 Types 指的就是抽象出来的某些数值的共性。

那么为什么需要把数值抽象为类型呢?

只因对于物体的操作也是具有抽象等级的,对于人的某个操作只能作用于人及其以下抽象等级的分类,如一个定义好的操作是给人配音,那么就只能是人或是其下的分类才能调用这个操作,所以将数据抽象为类型是为了函数能够正常执行逻辑。

尽管机器只需要处理 0 和 1,根本没有类型的概念,但若是没有类型,这些执行的正确性难以保证,就像是猴子乱按键盘所产生的结果,也许能够创造出一部文学作品,不过大多时候都是难以解析的内容。因此,高级编程语言几乎都存在类型,只是有些是动态类型语言,有些是静态类型语言。动态类型语言在运行期检查类型的正确性,而静态类型语言在编译期检查类型的正确性。

C++ 就是静态类型语言,每个数值都有其类型,类型匹配的数值能够完成一些操作,如 1 和 2 的类型是 int,便可以执行相加操作。总体而言,静态类型具有以下优势:

抽象难度更低,无须再去关注具体的数值,便能够对程序的行为进行高层次的分析和理解;程序的可读性更强,类型为人类提供了额外的结构信息;代码生成的效率更高,类型信息能够起到预置假设的作用,独立编译程序的各个模块也成为可能;错误检查更早,在编译期就可以过滤掉可能会在运行期发生的某些错误,某些 IDE 甚至可以借助类型信息在未编译程序的时候就发现某些语法、结构、行为等方面的错误。

然而,处理类型也给程序员带来了极大的负担,经常需要记住很多不太重要的类型名称,例如 C++ 中的迭代器,类型又臭又长,反而会影响代码的可读性。解决办法就是类型推导(Type Inference),这也是 C++11 开始引入 auto 自动推导类型的原因。

动态类型语言与静态类型语言相对,优劣相反,这类语言会采用类型注解(Type Annotations)来弥补其劣势。例如,Python 自 3.5 版本也开始支持手动指定类型信息。

既然类型如此重要,编译语言又是如何抽象类型的呢?

类型是数值抽象出来的共性,自然也有分类的方法,依照数值个数,可以二分为有限的数值和无限的数值。

有限的数值比较特殊,是首先需要分析的,因为无限中蕴含着有限,没有有限就没有无限。

最为特殊的是 0 个数值的类型,即“空”,表示这个类型不包含任何数值。C++ 中并不存在这个类型,而某些编程语言里面是存在的,例如 Rust 中的 !(Never Type)类型、Haskell 中的 Void 类型、TypeScript 中的 never 类型和 Scala 中的 Nothing 类型。

“空”类型为什么最为特殊呢?由于这个类型不能包含任何数值,实际上是无法被创建的。以“空”类型为参数的函数永远无法被调用,没有任何数值能够传递进去匹配这个函数。因此,这个类型常常用于不可能分支或是不可能返回的情况,只要出现这个类型,编译器就可以静态地识别出不可能发生的情况。

看以下 Haskell 中的一个函数:

absurd :: Void -> a

absurd 是函数名,Void 是参数类型,a 是返回值类型。在形式逻辑中,有一个推理规则称为 ex falso quodlibet(拉丁文),表示从矛盾中可以推出任意命题。比如,假设命题 1 + 1 = 3 成立,那么推导出“我是宇宙之王”也是成立的,因为矛盾一旦成立,逻辑大厦就崩塌了。absurd 函数表示的就是这条规则,Void 是不可能的值,所以返回值类型为 a(any,任意值),指的是从不可能中推导一切,所以这个函数叫 absurd(荒诞至极)。

再看一个 Rust 的例子:

fn never_returns() -> ! {   panic!("This will always panic");}

never_returns 函数的返回值类型是 !,而没有值属于这一类型,所以这是一个永远无法返回的函数。此外,Rust 中的 unreachable!() 宏也是基于 ! 类型实现的,而 C++ 没有“空”类型,所以直到 C++23 才通过别的方式实现了 std::unreachable()。

当然,定义出“空”类型并非难事,如:

enum nothing {};void g(nothing) {}int main() {    // No way to invoke f    g(???);}

g() 的确永远无法被调用,只是自定义的 nothing 无法作为返回类型,这个类型没有任何值,遂无法编写返回语句,突破不了语法检测这一关。

接着来看只有 1 个数值的类型,这便是 C++ 中的 void 类型。由于这个类型只存在一个可能的值,所以通常不需要显式地写出来。例如:

void f() {}

这里 f() 的参数类型并不是“空”类型,C++ 中不存在该类型,这里其实是 void 类型。因为该类型只存在一个实例,所以不需要在参数中显式指出。同时,这也是可以省略返回值的原因(如果显式地在参数中写出 void,调用时同样可以省略)。

其他编程语言也存在相似的类型,比如 Haskell 和 Rust 中的 (),Python 中的 None。看个 Rust 的例子:

fn say_hello() {    println!("Hello, world!");}fn main() {    let x = {        say_hello();    };    // Output: x = ()    println!("x = {:?}", x);}

say_hello() 的返回类型便是 (),称为 unit type,只不过此处并没有显式地写出来,其实完整的函数签名为:

unit type 只存在一个可能的值,可以用来作为没有意义的返回值,这并不是“空”类型,“空”类型定义的函数永远也无法返回。Rust 中所有表达式都需要一个返回值,即使什么也不会返回,因此 let x = { ... } 最终也是隐式返回的 ()。

2 个数值的类型就是 bool,表示真与假、是与非、阴与阳……返回类型为 bool 的函数称为 Predicates。但由于要处理可能的错误,C++ 中的许多 Predicates 实际上返回的是 int 类型。

有限的数值类型,只有 0、1、2 是比较特殊的,超过 2 个就是一些平常的类型,如表示数值的 int,表示字符的 char,表示小数的 double 等。可以通过自定义的方式来创建任意有限个数的数值类型,这通常用于表示状态。

考虑到内存占用,有限数值中的类型会再划分为不同的抽象等级,所以存在 short、int、long、long long 等等不同宽度的类型。而有时需要切换数值的类型,将抽象层次低的转变为抽象层次高的,顺序不能相反,这也就是所说的类型转换。

通过有限的数值类型,可以构建无限的数值类型,例如 String 就是一种常见的无限字符值。容器类型则是某种意义上的双重数值集合,其元素类型本身就是由集合构成的类型表示。

值得注意的是,并不存在一个能够抽象所有数值共性的类型,前面分析 Type 概念的时候也提到过抽象是没有尽头的,只有逻辑崩塌之时才有可能存在这样的类型。C++ 的模板参数类型也需要实例化成具体的类型才能够使用,std::any 利用类型擦除技术所达到的类似效果也并没有抽象出所有类型的共性,所以使用之时依旧需要类型转换。

最后一种相对特殊的类型能够包含函数和上下文信息,也就是 Closure Type(闭包类型)。这种类型是编译器在定义闭包的上下文中动态生成的,因此无法用一个具体的、全局通用的名称来表示,伪代码表示为:

closure = {    function = λ(x). x + y    environment = { y = 10 }}

闭包就是 C++ 中的 Lambda 函数,没有具体的函数名称,所以其类型也只能隐式地生成,由 auto 推导出来。

0 阅读:41