0%

CPP设计-滥觞

一、总论

我们常听一个说法“在语法上,C++ 是 C 的超集”,这句话的意思是说,C++ 继承并发展了 C 。这是十分有道理的事情,但是这个说法往往造成两个常见的误解:

  • C++ 继承 C 最重要的东西就是 C 的语法
  • C++ 是完全兼容 C 的

这两点其实并不正确。C++ 作为一门独立的语言,虽然起源于 C ,但是在设计思想上和 C 可以说是截然不同,但是又因为起源于 C ,在设计时又不可避免的带上了 C 的烙印,本文希望探讨一下 C++ 与 C 的继承和发展关系,故起名为“滥觞”。

这章可以看作是“CPP设计-寸步难行”的姊妹篇,这章介绍了 C 和 C++ 的不同,“寸步难行”介绍了 C 和 C++ 的相同之处。

二、零成本抽象

在我个人看来,C++ 真正传承 C 的核心是对于底层硬件环境的“掌握感”。不像 python 或者 ruby 这样的解释型语言,解释器为语言构造了一个极为舒适的“环境”:内存只需要分为堆和栈两个部分,符号表是井然有序的,运行时维护着十分详细的元信息…… 这些舒适的环境使得 python 或者 ruby 可以进行许多非常天马行空的抽象,比如说 lambda ,元编程等。

但是 C 和 C++ 不一样,他们运行的环境就是底层硬件,当然还有如 Go 这样的编译型语言也是如此,但是并不是所有的编译型语言都和 C 或者 C++ 一样,要对硬件有着绝对的掌控,虽然这是以“直面硬件环境的所有不利条件”为代价的。

但是 C 与 C++ 也是不同的,如果将 python 或者 ruby 的运行环境比作舒适的赛车跑道的话,那么 C 和 C++ 的运行环境就是破烂的丛林路。但是 C 对于这种破烂的泥土路其实是非常自得的,因为 C 更像是一种“压路机”一样的存在,天生就是为了处理这种破烂丛林路的,也就是天生就是干“脏活”的,我从来没见过任何一种语言可以像 C 一样如此直接的和硬件沟通:

#define VIRTIO (0x10001000ULL)
#define VIRTIO_ADDRESS(r) ((volatile u32 *)(VIRTIO + (r)))

对于设备的读写只需要声明一个地址,然后就可以直接读了。

inline void putcharSbi(char c)
{
    register u64 a0 asm("a0") = (u64)c;
    register u64 a7 asm("a7") = (u64)SBI_CONSOLE_PUTCHAR;
    asm volatile("ecall"
                 : "+r"(a0)
                 : "r"(a7)
                 : "memory");
};

对于寄存器和汇编也可以直接内嵌。

这种舒适性或许是因为 C 设计的初衷就是为了提供一种更加舒适的“汇编”,“汇编”在这个比喻环境中就是“犁”,“压路机”是自动化的“犁”。

但是 C++ 的目的并不是为了提供一种更加强大的“压路机”,就像 C 对于汇编做的改进那样。C++ 想为使用者提供更“强大”的语言服务,方便用户用这些服务构建更为符合人类心智模型的程序,也就是更好的“抽象”。但是很容易想到,抽象能力强的语言,就好像赛车一样,需要专业的跑道才能发挥作用,python 之所以强悍,和它在运行时有着丰富的元数据是密不可分的,解释器就像是那个专业的跑道,为 python 的强大抽象能力提供了驰骋的可能性。但是 C++ 因为继承了 C 对于硬件的“责任感”,所以十分奇怪地不要求在自己驰骋的时候要有一个“专业跑道”,也就是说,C++ 是一个“在丛林路上跑的赛车”。

“在丛林路上跑的赛车”很好地比喻了“零成本抽象”这件事情,C++ 既要强大的语言抽象能力(OOP、函数式编程、异常处理机制、元编程……),又不肯接受一丝一毫为了维持这种强大语言抽象能力所需要付出的成本(更好的符号表、更全的运行时环境、更符合心智的虚拟内存管理模型),在 C++ 的设计理念中,这辆赛车可以有更好的引擎,可以有飞天的功能,但是就是不能有一条稍微舒适一点点的跑道。

所以无论 C++ 的机制多么的花哨,它往往都是可以被翻译成 C 的,这就意味着它往往都是能被翻译成硬件能够理解的形式。也只有 C++ 这么执着这件事情。这是 C++ 真正继承 C 的东西,而不是简单的语法问题。

当然在“零成本抽象”这种“既要还要”的设计思想背后,其实意味着程序员要付出更多的心智在使用这门语言上。这就好像在丛林路上驾驶赛车跑到最高速度,也并不是没有可能,只是要车手足够的强大即可。这也是 C++ 存在的意义,也就是有些需求,不仅要求抽象程度高,而且要求性能好,唯一不要求的就是程序员要轻松。用 C++ 写出来的好的程序,真的是人类工程史的丰碑。

三、进击的 C++

3.1 总论

正如前所述,C++ 并不反感对于“赛车本身性能提升所付出的代价”,显然让 C 这种压路机跑出赛车效果并不现实,所以对于 C 的很多东西进行替换和修正,并不是很奇怪的事情。也就是说,C++ 并不是 C 的超集,C 和 C++ 的设计目的并不相同。

下文会介绍一些 C++ 独有的语法。这种语法一般是和 C 不同且是 C 的改进,当然如果比较大型,就不会出现在这里,而是会出现在专门的一章了。

写完后看了看,基本上都是和“类型”有关的 C++ 改进了,不知道是不是巧合。

3.2 C++ 的丑陋

“零成本抽象”已经使得 C++ 在语法上面比较复杂了,而“继承 C ”这一点无疑使这个问题更加恶化。因为其实关键字就这么多,如果 C 占据了,那么 C++ 就没法使用,所以如果 C++ 希望有自己的特色,就必须使用那些比较生僻和拗口的关键字,这也是导致 C++ 丑陋的一个重要原因。

3.3 C++ Style

3.3.1 void* 与 nullptr

在 C 和 C++ 中有 void* 这个类型,可以指向任意类型的数据,也就是任何指针类型都可以隐式转换成 void* 如下所示:

int *a;
void *p;
p = a; // 发生隐式转换

而“空指针”是一种可以表示任何指针类型为 null 的常量,所以在 C 的实现思路上是这样的:

#define NULL ((void*)0)
int *a = NULL;

这种方式可以看作是 void* 特性的对立面,也就是 void* 可以隐式转换成任何指针类型,很遗憾,C++ 并不支持这个特性(因为这个特性很没有道理,在 C 中被支持也只是 gcc 的特例)。

为了解决这个问题,C++ 引入了 nullptr_t 这个类型,,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较。这个类型有一个常量,就是 nullptr

总之,在 C++ 中,不要使用 NULL ,而是要使用 nullptr

3.3.2 constexpr

在 C 和 C++ 中都有 const 关键字,但是这个关键字并不是为了表示这个变量是“常量”,而是为了表示这个变量是“只读”的,一个变量即使被 const 修饰,在编译期也是没有办法确定值的,如下所示:

int a;
std::cin >> a;
const int b = a; // 编译通过
std::cout << b << std::endl;

即使依赖用用户输入的 b 也可以是 const 的,所以说明 const 并不是可以表示“编译期常量”这个意思,而我们又希望表示有一个量可以表示编译期常量,因为常量的特性要更好(比如说可以用于声明数组长度),在 C 中我们通常使用 define 来完成,这种方法缺少类型检查,所以在 C++ 中我们使用 constexpr 表示编译期常量:

constexpr int len_constexpr = 1 + 2 + 3;
char arr[len_constexpr]; // 编译通过

在高版本的 C++ 中甚至可以将递归函数识别成编译期常量(如果它真的是的话),如下所示:

constexpr int fibonacci(const int n) {
    return n == 1 || n == 2 ? 1 : fibonacci(n - 1) + fibonacci(n - 2);
}

3.3.3 auto

在传统 C 和 C++ 中,参数的类型都必须明确定义,这其实对我们快速进行编码没有任何帮助,尤其是当我们面对一大堆复杂的模板类型时,必须明确的指出变量的类型才能进行后续的编码,这不仅拖慢我们的开发效率,也让代码变得又臭又长。

auto 的本质是让编译器在编译期通过初始值自动推导类型,所以 auto 定义的变量必须有初始值

auto i = 3; // auto = int
auto g; // wrong

3.3.4 decltype

除了 auto 可以用于自动推导类型外,decltype 也可以用于推导类型,它接收一个表达式,并返回这个表达式的类型(并不会对这个表达式求值),decltype 并不会忽略 const, &, * 这样的符号:

int i = 0;
decltype(i) di; // int
const int ci = 0;
decltype(ci) dci; // const int
int *pi = &i;
decltype(pi) dpi; // int *
int &ri = i;
decltype(ri) dri; // int &

decltype 也可以与其他修饰符号结合,如下所示:

int i = 0;
decltype(i)& ri = i;       // int &
decltype(i)* pi = &i;      // int *
const decltype(i) ci = i;  // const int

此外 decltype 还有一些高级的用法,主要形式是这样的 decltype((expression)) ,但是我没有太看懂,只记录一下其中一种情况:

int i = 0;
decltype((i)) d = i; // int&

此外 decltype(), auto 也衍生了一种尾返回类型的需求,因为类型推导依赖前面出现的文本,而返回值在语法上出现在函数参数之前,所以传统 C++ 无法用函数参数去推导返回值类型,为了解决这个问题,我们诞生了这种写法:

template<typename T, typename U>
auto add2(T x, U y) -> decltype(x + y){
    return x + y;
}

3.3.5 range for

对于数组这样的可迭代对象,可以写出更为简洁的 for 循环,搭配不同的 const, & 有不同效果:

std::vector<int> vec = {1, 2, 3};

// 拷贝可写
for (auto elem : vec){}

// 拷贝只读
for (const auto elem : vec){}

// 引用可写
for (auto &elem : vec){}

// 引用只读
for (const auto &elem : vec){}

我个人感觉似乎后面两种的可用性更高一些,因为拷贝的代价是很大的,而且也与传统的 for 循环没有对应关系。

3.3.6 多返回值

多返回值涉及“多返回值的生成”和“多返回值拆包”,我们可以利用如下机制在 C++ 实现多返回值:

#include <iostream>
#include <tuple>

std::tuple<int, double, std::string> f() {
    return std::make_tuple(1, 2.3, "456");
}

int main() {
    auto [x, y, z] = f();
    std::cout << x << ", " << y << ", " << z << std::endl;
    return 0;
}

3.3.7 强枚举类型

在传统 C++中,枚举类型并非类型安全,枚举类型会被视作整数,则会让两种完全不同的枚举类型可以进行直接的比较(虽然编译器给出了检查,但并非所有),甚至同一个命名空间中的不同枚举类型的枚举值名字不能相同,这通常不是我们希望看到的结果。

C++11 引入了枚举类(enumeration class),并使用 enum class 的语法进行声明:

enum class new_enum : unsigned int {
    value1,
    value2,
    value3 = 100,
    value4 = 100
};

这样定义的枚举实现了类型安全,首先他不能够被隐式的转换为整数,同时也不能够将其与整数数字进行比较, 更不可能对不同的枚举类型的枚举值进行比较。但相同枚举值之间如果指定的值相同,那么可以进行比较:

if (new_enum::value3 == new_enum::value4) {
    // 会输出
    std::cout << "new_enum::value3 == new_enum::value4" << std::endl;
}

在这个语法中,枚举类型后面使用了冒号及类型关键字来指定枚举中枚举值的类型,这使得我们能够为枚举赋值(未指定时将默认使用 int)。

3.3.8 using alias

在 C 中我们用 typedef 去定义一个新的类型,而这种方式在定义函数指针的时候,并不是那么清晰直观,因为新的类型名称被放在了原类型内部,如下所示:

// FP 是 void (*)(int, const std::string&) 类型
typedef void (*FP) (int, const std::string&);

但是如果用 using 去定义一个新的类型,就会相对直观一些:

using FP = void (*) (int, const std::string&);

除了定义清晰的特点外,using 的优势还在于可以和模板相结合,而 typedef 并不具备这样的能力:

template <typename T>
using Vec = MyVector<T, MyAlloc<T>>;

// usage
Vec<int> vec;

总之,用 using 代替 typedef 即可。

3.3.9 cast

相比于 C 不加检验的类型转换系统,C++ 提出了更加复杂的类型转换系统,使得类型转换更加安全。但是我个人感觉有些类型转换,总有一种没那么必要的感觉,也没有办法很了解具体的应用场景是什么。

const_cast 可以让类型加上或者去掉 const ,据说也可以让类型加上和去掉 volatile

dynamic_cast 可以用于基类和衍生类之间的转换,其中基类如果转换成衍生类,那么很容易是不安全的,dynamic_cast 可以在运行时检测到这种“不安全”,对于无法安全转换的情况,dynamic_cast<Derived*>(Base_value) 会返回 nullptr

dynamic_cast 的优点最为明显,运行时安全是十分重要的,但是缺点也很明显,它的转换类型只能是引用或者指针,同时开销比较大。

上面两种转换的应用场景都很明显,而后面两种的使用场景和功能都是十分模糊的,不过似乎是这样,就是 C++ 希望提出自己封闭自洽的类型转换系统,彻底消灭 C 的隐式类型转换和显式类型转换,如下所示:

int a = 2;
long long b = a;
char c = (char) a;

所以在我的理解里,剩下的 static_cast 就是用于“讲道理”的类型转换,这种转换也会检查一下,但是开销不如 dynamic_cast 大,效果也不如它好,只能检查出不同指针类型间不合理的转换。

reinterpret_cast 则是用于“故意不讲道理”的类型转换,比如故意将一个 int 转换成 float 的情况。

在排除了上面两种情况后,剩下的就是“不小心不讲道理”的情况,也正是程序员需要避免的情况。但是有一说一,xxx_cast<>() 实在是太长了,真的不如 C-style 舒服。

3.3.10 new / delete

CPP 舍弃了 C 的 malloc()free() ,而改用 newdelete 。举例如下:

// 分配一个数组
int *a = new int[5];
// 分配一个对象
Student *pcnx = new Student("cnx", 19);
// 释放数组
delete [] a;
// 释放对象
delete pcnx

malloc/free 只是分配堆上的空间,但是 new 在分配空间的同时,还会自动计算堆上需要的内存空间,并且调用类的构造函数。同理, delete 也会计算释放的内存量,并调用析构函数。这也无怪乎要有 delete [] 这种形式了,因为这代表着需要调用多次析构函数。

定位 new 用法也说明了 new 在除了分配内存(这种用法不分配内存)外的其他贡献。这是 new 的一种新用法,其实就是可以指定分配位置的 new。(之前是分配在堆上,是由操作系统负责的)。他的用法如下:

#include<new>

char buffer[512];

pd1 = new(buffer) double[3];

3.3.11 namespace

符号冲突对于 C 和 C++ 都是一个难以避免的问题,C 选择的是尽量使用 static 或者 local 的变量,这样可以避免全局变量的冲突,但是对于函数,都是全局的,符号问题就需要靠程序员用人脑避免了。

但是 C 的符号问题还好,这是因为 C 对于外部库的需求不大,经常是裸机开发,但是对于 C++ 而言,会使用大量第三方库(应该吧),而且本身 C++ 的码量也容易比 C 多,这会造成更多的符号冲突。因此 C++ 发明了 namespace 机制去解决这件事。

这里举一个很全面的例子

#include <iostream>

int a = 10;

namespace N
{
    int a = 100;
}

// 相同的 namspace 可以多次声明
namespace N
{
    void f()
    {
        int a = 1000;
        std::cout << a << std::endl;      // 1000, 不进行域解析就表示当前 namspace
        std::cout << N::a << std::endl;   // 100,
        std::cout << ::a << std::endl;    // 10, :: 开头表示全局空间
    }

}

int main(int argc, char *argv[])
{
    N::f(); // 外部调用要加上域解析符
    return 0;
}

此外还有一个匿名 namespace 的用法,用于代替 C 中的 static ,如下所示:

namespce {
    char c;
    int i;
    double d;
}

// 等价于:

namespace __UNIQUE_NAME_ {
    char c;
    int i;
    double d;
}
using namespace __UNIQUE_NAME_;

命名空间都是具有 external 连接属性的,只是匿名的命名空间产生的 UNIQUE_NAME 在别的文件中无法得到,这个唯一的名字是不可见的。