跳转至

Filling in the Gaps

本节我们会继续填补一些 C 与 C++ 之间的差异,这些差异点对理解很多 C++ 概念都是至关重要的。

命名空间与作用域解析运算符

C++ 引入了命名空间namespace的概念,例如标准库定义的所有函数都在 std 命名空间中。命名空间解决了 C 中的全局命名冲突问题,也能够实现代码的逻辑分组。:: 称为作用域解析运算符scope resolution operator,用于指定不同作用域中的成员,包括命名空间、类、结构体、枚举等。

a.c
int var = 20;
b.c
int var = 40;

a.cb.c 无法链接在一起,因为它们都定义了名为 var 的全局变量,解决办法只能是给其中至少一个变量加上 static 修饰符,使其变为文件作用域。在 C++ 中,可以使用命名空间来解决这个问题:

a.cpp
1
2
3
namespace A {
  int var = 20;
}
b.cpp
1
2
3
namespace B {
  int var = 40;
}
c.cpp
int var = 60;

现在就有两个完全独立的变量:A::varB::var,分别意味着命名空间 AB 中的 var。当然,仍然可以有全局变量,如 c.cpp 中的 var,这时可以使用 ::var 来指代全局命名空间。这种方式不只在解决命名冲突时有用:

int var = 60;

namespace A {
  int var = 20;

  void func() {
    int var = 10;

    std::cout << var << "\n";    // 10
    std::cout << A::var << "\n"; // 20
    std::cout << ::var << "\n";  // 60

    if (true) {
      int var = 5;
      std::cout << var << "\n";    // 5
      std::cout << A::var << "\n"; // 20
      std::cout << ::var << "\n";  // 60
    }
  }
} // namespace A

这里发生了遮蔽shadowing:每一个作用域都有自己的 var 并且遮蔽了上一层作用域的 var。如果变量能通过作用域解析运算符访问,那么就可以这样做,例如 A::var::var;但如果是局部变量,就束手无策了,例如 funcif 块中的 var 遮蔽了上一层同样为局部变量的 var,这种情况只能通过改变变量名来解决。

命名空间可以任意嵌套:

namespace A {
  namespace B {
    namespace C {
      int var = 20;   // A::B::C::var
    }
  }

  using B::C::var;    // A::var === A::B::C::var
  namespace D = B;    // A::D === A::B
  namespace E = B::C; // A::E === A::B::C
}
namespace F = A::E;   // F === A::B::C

可以使用 using 语句来引入命名空间中的某个成员,或使用 namespace 赋值来引入命名空间的别名,或通过 using namespace 引入命名空间的所有成员。using 语句并没有定义新的变量,只是将原有的变量引入到当前作用域中——命名空间是编译时的概念,运行时并不存在,不要将其与作用域混淆。

int f() { return A::B::C::var; }
int g() { return A::var; }
int h() { return A::D::C::var; }
int m() { return A::E::var; }
int n() { return F::var; }
int p() {
  using A::B::C::var;
  return var;
}
int q() {
  using namespace A::D;
  return C::var;
}
int r() {
  using namespace A;
  return var;
}
using namespace A::E;
int s() { return var; }

上面的所有函数全部返回同一个变量 A::B::C::var。但是函数 s 与其他函数不同,它依赖于它前面的全局 using namespace 语句。应该避免在全局作用域中使用 using namespace 语句——最臭名昭著的例子就是 using namespace std;,它会引入大量的标准库成员,完全破坏了命名空间存在的意义。我们已经在 Bootstrap 中提到过,最好使用单独的 using 语句来只引入需要的成员。

还有一些较为小众的细节,请参阅 cppreference 中名字查找的相关内容。

另外,using 同样是 C 中 typedef 的上位替代品。它的语法要比 typedef 更符合直觉,并且在涉及模板时,只能使用 using 语句。

1
2
3
4
5
6
7
8
9
using uint32_t = unsigned int;
typedef unsigned int uint32_t;

using int_bin_op = int (*)(int, int);
typedef int (*int_bin_op)(int, int);

template <typename T>
using map_to_int = std::map<T, int>;
// not possible with typedef

匿名命名空间

匿名命名空间,顾名思义,就是没有名字的命名空间。它的作用类似于使用 static 修饰,在匿名命名空间中定义的变量或函数只能在当前文件中访问,不会影响其他文件。

1
2
3
4
5
namespace {
  int var = 20;
}
// effectively static int var = 20;
void f() { std::cout << var << "\n"; }

初始化

C 中的初始化就是简单的赋值,而 C++ 中的初始化则更加复杂。例如,自 C++11 起,要初始化一个 int 变量可以有下面 4 种写法:

1
2
3
4
int a = 10;   // copy initialization
int b = {20}; // list initialization (*)
int c(30);    // direct initialization
int d {40};   // list initialization (since C++11)

好消息是,第 2 种写法在 C++11 之前被称作“聚合初始化”,自 C++11 起同样被归为列表初始化,所以实际上只有 3 种初始化方法;坏消息是,这 3 种初始化方法在某些情况下仍然会有不同的行为。不同初始化方式背后还受到一些其他因素的影响,例如 explicit 关键字,或者类的构造函数1。一般而言,如果确定自己在写 C++11 以后的版本,就应该尽量采用第 4 种写法。目前可以认为第 4 种写法是第 1 和 3 种写法的某种综合。下面 5 种写法全都产生同一个字符串 "Hello"

1
2
3
4
5
std::string a {"Hello"};
std::string b {'H', 'e', 'l', 'l', 'o'};
std::string c {{'H', 'e', 'l', 'l', 'o'}};
std::string d {c};
std::string e {{d}};

不过可以相信自己,在这里 b cd e 之间分别是等价的,但并不总是这样。我们会在后面 STL 的 std::initializer_list 中看到区别的情况。

变长数组

C 支持变长数组variable-length array

C
1
2
3
int n;
scanf("%d", &n);
int arr[n]; // OK

然而很遗憾,VLA 并不包含在 C++ 标准中,也就是标准 C++ 不支持 VLA,只是 GCC 与 Clang 都将其作为扩展语法提供支持,在 C++ 中应当尽量避免。合理的替代品是 std::vector 甚至 std::unique_ptr

C++
1
2
3
int arr[n];                             // not standard C++
std::vector<int> varr(n);               // good
auto parr {std::make_unique<int[]>(n)}; // OK

关键字

decltype

尽管课上不会涉及,但 decltype 是 C++11 引入的非常有用的关键字之一,它的作用与 GCC 扩展语法 typeof 类似2,用于获取表达式的类型:

1
2
3
4
5
6
int i = 10;
decltype(i) j = 20;  // int j
decltype(30) k = 40; // int k

struct { int x; } s; // anonymous struct
decltype(s) t;       // same type as s

但上面的例子实在太刻意,实际上 decltype 更多的用于模板元编程和 lambda 表达式中。上面的例子用 auto 更好,其与 decltype 也有密切联系。

auto

在 C 中,auto 指定一个变量为自动变量,即创建与销毁都与作用域绑定。autostaticexternregister 一起被称为存储类别说明符。然而,由于局部变量默认就是自动变量,所以 auto 几乎没有存在的必要。自 C++11 起,auto 有了新的含义:自动类型推导。在声明变量时使用 auto 关键字,编译器会尝试根据变量的初始化表达式推导出变量的类型。

1
2
3
auto i = 10;      // int i
auto d = 3.14;    // double d
auto s = "hello"; // const char *s

当然,这要求必须存在初始化表达式,否则编译器无法推导出变量的类型。下面的写法是错误的:

1
2
3
auto i; /* error: declaration of variable 'i' with deduced
                  type 'auto' requires an initializer */
i = 20;

auto 还可以作为函数返回值的类型,这时需要后随 -> 与返回类型(自 C++14 起,返回类型同样可以省略):

1
2
3
4
5
6
7
8
auto f() -> int {
  return 10;
}

// since C++14
auto g() {
  return 20; // deduced return type: int
}

这在某些复杂的模板类型推导中非常有用(不必理解):

// since C++11
template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u) {
  return t + u;
}

// 如果不采用 auto 后随返回类型的写法
template <typename T, typename U>
decltype(std::declval<T>() + std::declval<U>())
add(T t, U u) {
  return t + u;
}

// since C++14
template <typename T, typename U>
auto add(T t, U u) {
  return t + u;
}

Info

实际上,decltypeauto引用,任意两者组合都能牵扯出庞大的语法规则。

const

const 是那种在 C 和 C++ 中作用基本相同,但底层原理完全不同的关键字。实际上 const 这个关键字是 C 从 C++ 中借鉴过来的,但其实在 C 中这个关键字改名叫 readonly 会更好,因为它只是声明一个只读变量,而不是一个常量。区别在哪呢?例如在 C 中,const 变量作为数组长度时,数组仍然被视作一个变长数组:

C
1
2
3
const int n = 10;
int a[n];  // VLA
sizeof(a); // evaluated at runtime (1)
  1. 但编译器仍然可能会做优化,使得 sizeof(a) 在编译时就能得到正确的值。

而 C++ 中,const 变量作为数组长度时,得到的数组是定长的:

C++
1
2
3
const int n = 10;
int a[n];  // array of 10 integers
sizeof(a); // compile-time constant

再比如,在必须需要常量表达式的地方:

C
1
2
3
const int x = 42;
_Static_assert(x == 42); /* error: expression in static
                                   assertion is not constant */
C++
const int x = 42;
static_assert(x == 42); // OK

还是有必要知道这个区别的,尽管大多数时候不需要关心。

nullptr

在 C23 以及 C++11 以前,表示空指针的方法是使用定义在 stddef.h 或者 cstddef 中的 NULL 宏,通常它会被定义为 #define NULL 00 可以隐式转换为任何指针类型,C 的简单语法允许这种转换发生,但这种转换与 C++ 的理念相悖,并且在某些情况下会引发问题。于是,C++11 引入了 nullptr 关键字用于表示空指针,它是 std::nullptr_t 类型的唯一一个值,可以隐式转换为任何指针类型。

一个 NULL 会出问题的例子

在 C 中,NULL 通常可能有多种定义的方式,例如:

1
2
3
#define NULL 0
#define NULL 0L
#define NULL (void *)0

C 允许 NULL 的这种松散的定义,并且这三种定义在 C 中一般都可以正常工作,但 C++ 不同。首先,在 C++ 中一定不能采用 (void *)0 这种定义,因为 C++ 不允许 void * 隐式转换为其他指针类型。我们一定不想让这样的写法报错:

1
2
3
/* error: cannot initialize a variable of type 'char *'
          with an lvalue of type 'void *' */
char *s = NULL; // char *s = (void *)0;

那么如果,NULL 被定义为 0L,考虑下面的例子:

1
2
3
4
void f(int);
void f(char *);

f(NULL); /* error: call to 'f' is ambiguous */

这里涉及函数重载。若 NULL 被定义为 0L,那么 f(NULL) 就会产生编译错误,因为 0L 既可以隐式转换为 int 也可以隐式转换为 char *,编译器无法确定调用哪一个 f。这是好的结果。更糟糕的是,如果 NULL 被定义为 0,那么 f(NULL) 就会调用 f(int),问题就会被推迟到运行时,并且极难排查。

类型转换

C++ 中的类型转换比 C 复杂得多。传统的 C 风格类型转换仍然可以使用,但是 C++ 向下细分出了 4 种类型转换。需要注意它们都是关键字而非函数或宏,只是语法上有点像模板函数:

  1. static_cast:用于一些“普通”的转换,以及有继承关系的类之间的转换。
    • 用于基本类型之间的转换,例如 static_cast<int>(3.14)
    • 用于指针类型与 void * 之间的转换,例如 static_cast<void *>(ptr),其中 ptr 是任意指针类型。
    • 用于兼容类型之间的转换,例如 static_cast<int &&>(i),其中 iint 类型。
    • 用于有继承关系的类之间的转换,例如 static_cast<Derived *>(base),其中 baseBase * 类型,并且 DerivedBase 的派生类。
      • 如果 BaseDerived 之间没有继承关系,那么产生编译错误。
      • 如果 base 的对象实际上不是 Derived 类型,那么行为未定义。
  2. dynamic_cast:利用运行时的额外信息(RTTI)来进行有继承关系的多态类之间的转换。
    • 例如 dynamic_cast<Derived *>(base)
      • 如果 Base 不是多态类,那么产生编译错误。
      • 如果 BaseDerived 之间没有继承关系,或 base 的对象实际上不是 Derived 类型,那么结果是 nullptr
  3. const_cast:能够去掉 cv 限定符3的转换。
    • 例如 const_cast<int *>(i),其中 iconst int * 类型,或者 volatile int * 类型,或者 const volatile int * 类型。
    • const_cast 通常不是个好主意,如果代码中存在很多 const_cast,那么肯定存在更深层的设计问题。尽量避免使用它,除非万不得已。
  4. reinterpret_cast:用于不同类型之间的转换。
    • 例如 reinterpret_cast<void *>(0x10200030),或者 reinterpret_cast<const uint8_t *>(p),其中 p 是任意非 volatile 限定的指针类型。

上面列出的情况只是简单情况的描述,并非完整。可以看到上面四种转换的职责各不重叠,都有自己的应用场景。需要注意的是,当转换涉及到 OOP 时,操作的对象总是指针或引用,而非对象本身,这是因为多态性只有在指针或引用的情况下才能发挥作用,对应的细节及应用我们会在介绍类以及引用的时候详细讨论。

C 风格转换仍然可以使用,但应该极力避免,因为它经常被滥用、容易出错且完全不把类型当回事。在正确的场合使用正确的转换更好,并且更能体现出你是个 C++ 高手。

内存分配

C 中动态内存分配的常规做法是 malloc / free 函数,它们在 C++ 中仍然可用,但问题在于 malloc / free 无法与 C++ 的 OOP 方面很好地结合,只能用于分配未初始化的内存,并且 void * 仍然是类型系统的大漏洞。所以 C++ 引入了 new / delete 运算符,它们是 C++ 动态内存管理的基础,并且会考虑到 OOP 的方方面面。对于数组,C++ 使用 new[] / delete[] 运算符。需要注意使用 new[] 分配的内存必须使用 delete[] 释放,而不能使用 delete,否则产生未定义行为。反之亦然。

1
2
3
4
5
6
7
8
9
int *a = new int;
auto *b = new char {42}; // auto = char
double *c = new double[10];
auto l = new std::list<int> {2, 3, 5, 7}; // auto = std::list<int> *

delete a;
delete b;
delete[] c;
delete l;

需要注意,对于基础类型,new 并不会替你做初始化;正如 int v; 声明的变量是没有初始化的并且具有不确定的值,在上面的例子中 *a 也是一样。如果需要初始化,可以使用 (){} 语法,如 b 所示。在这种情况下 auto 就非常有用了,能够让我们免去一次类型声明的麻烦,如 bl 所示。另外,c 就是 new[] 的例子,分配了足够容纳 10 个 double 值的空间,对应地需要使用 delete[] 释放。

但必须说明,new / delete 仍然是 C++ 中的底层内存管理方式,相比 malloc / free 来说,它适配了 OOP 的方面,但仅限于此;内存仍然需要手动管理,内存泄漏、越界、二次释放等问题仍然可能发生,所以应该谨慎使用。C++ 有更好的方式来管理内存,即使用智能指针,背后其实还涉及到称作 RAIIResource Acquisition Is Initialization 的设计模式,以后我们会看到。

重载

在 C++ 中,我们可以定义多个同名函数,只要它们的参数列表不同,这就是函数重载overloading,这是 C++ 一个极其重要的特性。例如在 C 中,没有函数重载,所以我们只能使用不同的函数名来区分不同的函数:

math.h
1
2
3
4
5
6
int abs(int);
long labs(long);
long long llabs(long long);
double fabs(double);
float fabsf(float);
long double fabsl(long double);
inttypes.h
intmax_t imaxabs(intmax_t);
tgmath.h
#define fabs(x) /* some definition using _Generic */

仅以计算绝对值为例,对于 7 个不同的类型,C 就需要 7 个不同的函数名!相信我们所有人都经历过给 abs 传递浮点数的痛苦。尽管 C11 引入了 _Generic 宏,但“宏”仍然是一种超脱 C 本身力量的工具。而在 C++ 中,我们可以使用同一个函数名 abs 来表示所有的绝对值函数:

cmath
1
2
3
4
5
6
int abs(int);
long abs(long);
long long abs(long long);
double abs(double);
float abs(float);
long double abs(long double);
cinttypes
std::intmax_t abs(std::intmax_t);

我们在上面介绍 NULL 会出问题的例子中,也演示了函数重载。总而言之,编译器能够根据调用函数时参数的类型来选择正确的函数。需要注意的是,返回类型并不是函数重载的一部分,所以不能仅仅通过返回类型来区分不同的函数。下面的都是正确的重载:

1
2
3
4
5
int *func();
void func(int);
void func(double);
float func(int, double, std::string);
std::vector<int> func(std::list<int>);

而下面的则是错误的重载:

1
2
3
int func();
void func(); /* error: functions that differ only in
                       return type cannot be overloaded */

另外,需要区分重载与遮蔽。重载的函数一定在同一个作用域中,而遮蔽则发生在不同作用域中的同名变量或函数上:

void func(int) { cout << "void ::func(int)\n"; }

namespace A {
  void func() { cout << "void A::func()\n"; }

  void caller() {
    func();
    func(20); /* error: no matching function for call to 'func' */
    ::func(20);
  }
}

这里发生的是遮蔽,而非重载;A::func 遮蔽了 ::func,所以在 A 的作用域中,func 总会被解析为 A::func,而不会去考虑 ::func。而 A::func 并不接受任何参数,所以第二个调用会产生编译错误,这种情况下必须调用 ::func。历年期末确实出现过这样的题目,特别是如果还涉及到继承的话。我们会在后面的章节中看到这样的问题。

结构体

在 C 中,结构体就仅仅是一系列类型的组合,并且在使用时需要显式写明 struct,或者使用 typedef

C
struct A {
  int x;
  double y;
};

struct A a1 = { .x = 1, .y = 2.0 };
A a2; // error: must use 'struct' tag to refer to type 'A'

typedef struct A A;
A a3; // OK

另外注意第 6 行的初始化方式,即 C99 引入的指派初始化式designated initializer。C 的语法有时会导致一些微妙且令人困惑的问题:

struct A { int x; };
int A = 2;

在这个例子中,A 是一个变量名而非类型名,要引用结构体 A 必须使用 struct A。C++ 做出的一个改动是,结构体的定义与使用更加简洁,不再需要显式写出 struct 或者 typedef。下面的例子中,struct AA 是等价的:

C++
1
2
3
4
5
6
7
struct A {
  int x;
  double y;
};

A a1;
struct A a2;

考虑 Bootstrapvector 的例子:

struct vector {
  int *data;
  size_t size;
  size_t capacity;
};

struct vector *vector_create(size_t capacity);
void vector_destroy(struct vector *v);
void vector_append(struct vector *v, int value);
void vector_remove(struct vector *v, size_t index);

在 C++ 中,结构体从“一系列数据的组合”进化为,它可以包含数据成员、函数成员,以及控制“谁可以访问这些成员”的能力。C++ 同时引入了 class 关键字,但其与 struct 并无本质区别,只是默认的访问权限不同。在 C++ 中,我们就可以这样定义 vector

class vector {
  int *data;
  size_t size;
  size_t capacity;

public:
  vector(size_t capacity);
  ~vector();
  void append(int value);
  void remove(size_t index);
};

我们可能还不熟悉这种写法。Construct & Destruct 会详细介绍对象的构造、析构与访问控制。

另外,与 C 一样,结构体与类都可以嵌套,也可以在函数内部定义,同样可以有位域。

默认参数

C++ 中的函数可以有默认参数,这是 C 没有的特性。默认参数必须从右向左依次出现,且只能在声明中出现一次。

1
2
3
4
5
void f(int x, int y = 10, int z = 20);

f(1);       // f(1, 10, 20)
f(1, 2);    // f(1, 2, 20)
f(1, 2, 3); // f(1, 2, 3)

用重载也可以实现类似的效果,但默认参数更加简洁。

1
2
3
void f(int x, int y, int z);
void f(int x, int y) { f(x, y, 20); }
void f(int x) { f(x, 10, 20); }

有默认参数的函数同样可以被重载,但需要注意调用时可能会产生二义性:

1
2
3
4
void f(int x, int y = 10);
void f(int x);

f(1); // error: call to 'f' is ambiguous

所以在设计函数时,应该避免重载与默认参数的混用。

另外,需要注意的是,默认参数是在调用点确定的,而非在声明点。简单来说,这意味着当函数调用时,编译器会将默认参数替换到调用点,然后在运行到这里时才会真正计算默认参数的值。这样的设计使得默认参数可以是任意表达式:

int g = 6; // global variable

int id(int i) {
  cout << "id(" << i << "); ";
  return i;
}

void f(int x, int y = id(g)) {
  cout << "x=" << x << ", y=" << y << '\n';
}

int main() {
  f(5, 10);
  f(1); // f(1, id(g));

  g = 20;
  f(2); // f(2, id(g));
}

// x=5, y=10
// id(6); x=1, y=6
// id(20); x=2, y=20

但这是一个可以成为陷阱的地方!例如:

#include <iostream>

void f(int x = __LINE__) {
  std::cout << "x=" << x << '\n';
}

int main() {
  f(); // 3
  f(); // 3
  f(); // 3
};

问题在于“宏”是一种更早的预处理阶段的概念,而默认参数是在编译阶段确定的。所以 __LINE__ 的值在 f 被声明时已经被替换为了一个确定的整数,在上面的例子中即 void f(int x = 3),这就是为什么每次调用 f() 时都会输出 3。

默认参数的用途

实际上,C++20 引入的 std::source_location 解决了这个问题,它反映了调用点的信息,可以用于调试。例如:

1
2
3
4
5
6
7
#include <source_location> // since C++20

void debug(const char *msg,
           std::source_location loc = std::source_location::current()) {
  cout << "[" << loc.file_name() << ":" << loc.line() << "] "
       << msg << '\n';
}

这样就可以在调试时输出调用点的文件名与行号。在 C++20 之前的传统写法是同样用宏来实现调试信息的输出,但总归不够优雅。

另外,还可以给已经有默认参数的函数再添加默认参数:

void f(int x, int y = 10) {
  cout << "x=" << x << ", y=" << y << '\n';
}
void f(int = 80, int); // OK

int main() {
  f(4);     // f(4, 10)
  f(8, 20); // f(8, 20)
  f();      // f(80, 10);
}

杂项

字符字面量的类型

sizeof('A') 在 C 与 C++ 会给出不同的结果;在 C 中结果通常是 4,而在 C++ 中结果一定是 1。这是因为在 C 中,字符字面量的类型是 int,而在 C++ 中是 char。这是少数 C 与 C++ 之间不兼容之处,尽管一般来说不会有什么问题。

字符串字面量的类型

此事在地址、指针、数组中亦有记载。C++ 中字符串字面量的类型是 const char [],而在 C 中是 char []。这也与历史原因有关,之前已经提到过 const 是 C 后期从 C++ 借鉴而来,如果 C 将字符串字面量的类型更改为 const char [],会破坏很多现有代码,于是 C 保持了原样。不管怎么说,在哪种语言中字符串字面量都不可被修改,否则是未定义行为。C++ 从语法上保证了这一点。

函数的 void 参数

在 C 中,与大多数人的认知不同的是,如果函数的参数列表为空,则表示函数接受的参数个数是未知的。必须要明确写出参数列表为空,即 void,这样才能表示函数不接受任何参数:

void f();     // 接受未知个数的参数
void f(void); // 不接受任何参数

前一种写法是 K&R C 的历史遗留问题,而后一种写法是 ANSI C 以后的标准写法。在 C++ 中不存在这个区别,这两种写法是完全等价的。


  1. 也许 C++ 委员会也发现了这个问题。自 C++17 起,复制消除copy elision写入了标准,在满足条件的情况下这可以省掉一次复制构造函数的调用,提高程序性能(尽管大部分现代编译器在 C++17 之前就已经在这样做了)。 

  2. 顺便一提,typeof 终于在 C23 中被正式引入,同时还有 typeof_unqual,但它们的作用与 decltype 不完全相同。C 终究只是 C。 

  3. constvolatile 限定符。 

评论