C++14指北:花里胡哨的C++

类型!

在最经典的 C++ 代码中,我们使用类似 类型名 变量名 = 表达式; 的形式声明并初始化变量,例如

int x = 1;
int y = x;

在上面代码中,我们知道 y 理应与 x 的类型相同,但是在上面代码中,如果我们后来把 x 的类型修改为 int64_t,而忘记对应地修改 y 的类型,则可能导致灾难性的后果。对此,我们最简单的做法是使用类型别名。在 C++ 中,可以使用 using 声明类型别名:

using my_int = int;
my_int x = 1;
my_int y = x;

我们也可以使用 decltype 说明符,这是在 C++11 中添加的新关键字:

int x = 1;
decltype(x) y = x;

decltype 关键字接受一个表达式,得到该表达式的类型。与 sizeof 类似,decltype 的操作数仅作为类型推导使用,并不会在运行时真正被求值。

验证 `decltype` 不会进行求值

考虑下面代码:

#include <cstdio>

int func() {
    std::puts("called!");
    return 0;
}

int main() {
    decltype(func()) a = 1;
    return 0;
}

执行后无输出,说明 decltype 的操作数不会在运行时进行求值。

`decltype` 可以保留 `void`、指针、值类型和类型限定符
查看类型推导结果

我们可以使用声明但未定义的模板函数来查看类型推导结果:

template<typename T>
void func();

该函数会阻止程序正确链接,链接器会按顺序汇报所缺失的函数特化,根据其报错信息即可得到 T 的类型。

考虑如下代码:

#include <utility>

template<typename T>
void func();

void void_func();

int main() {
	int y;
	int &y2 = y;
	int *y3;
	const int * volatile *y4;
	func<decltype(y)>();
	func<decltype(std::move(y))>();
	func<decltype(y2)>();
	func<decltype(y3)>();
	func<decltype(y4)>();
	return 0;
}

链接时,你会得到类似于如下的报错信息:

a.cpp:(.text+0x16): undefined reference to `void func<int>()'
a.cpp:(.text+0x1b): undefined reference to `void func<int&&>()'
a.cpp:(.text+0x20): undefined reference to `void func<int&>()'
a.cpp:(.text+0x25): undefined reference to `void func<int*>()'
a.cpp:(.text+0x2a): undefined reference to `void func<int const* volatile*>()'
a.cpp:(.text+0x2f): undefined reference to `void func<void>()'
collect2: error: ld returned 1 exit status

这说明,decltype 推导可以正确保留 void、指针、引用和类型限定符(constvolatile)。

值得注意的是,被括号包裹的标识符表达式或类成员访问表达式将被推导为左值:

template<typename T>
void func();

int main() {
	int y0 = 1;
	func<decltype(y0)>();
	func<decltype((y0))>();
	return 0;
}

这将产生类似于如下的链接错误:

a.cpp:(.text+0x15): undefined reference to `void func<int>()'
a.cpp:(.text+0x1a): undefined reference to `void func<int&>()'
collect2: error: ld returned 1 exit status

同时使用类型别名和 decltype 推导,我们可以把一些较长的类型名称存储下来,例如:

std::vector<int> a;
using It = decltype(a.begin());
不经过构造函数直接使用成员函数

std::declval 可以将任意类型 T 转换成引用类型,使得在 decltype 的操作数中不必经过构造函数就能使用成员函数。考虑下面代码,由于结构体 A 没有构造函数,于是不能通过编译:

struct A {
	A() = delete;
	int foo();
};

int main() {
	decltype(A().foo()) x;
	return 0;
}

使用 std::declval 即可解决这个问题:

#include <utility>

struct A {
	A() = delete;
	int foo();
};

int main() {
	decltype(std::declval<A>().foo()) x;
	return 0;
}

当然,许多时候 decltype 也是不方便的,大部分时候所需的类型是与初始化表达式一致的,而使用 decltype 会导致大量重复,考虑下面代码:

int x = 1;
decltype(func(x) * 2LL) y = func(x) * 2LL;

这时,我们就可以使用大名鼎鼎的占位类型说明符 auto 来简化代码:

int x = 1;
auto y = func(x) * 2LL;

自 C++14 起,函数的返回值(如果可以自动推导)也可以是 auto

auto add(int x, int y) {
	return x + y;
}
不能进行返回类型推导的几种常见情况

函数在形式上有多个 return,且这些 return 返回的类型不相同(即使可以隐式类型转换):

auto func(int x) {
	if (x) return 1LL;
	return 0;
}

函数返回初始化列表:

auto func() {
	return {1, 2};
}

要求 auto*,但是返回值不是指针:

auto* func(int x) {
	return x;
}

在递归前不存在可推导的返回语句:

auto func(int x) {
   return x == 0 ? 0 : func(x - 1);
}

作为对比,下面写法是可以的:

auto func(int x) {
	if (x == 0) return 0;
	return func(x - 1);
}

虚函数不能使用返回类型推导:

struct F {
	virtual auto f() {
		return 2;
	}
};

占位符 auto 本身会丢失类型限定符,因此占位符 auto 可伴随如 const& 这样的修饰符,它们参与类型推导。考虑下面代码:

template<typename T>
void func();

int main() {
	int y0 = 1;
	const int &y1 = y0;
	auto y2 = y1;
	func<decltype(y1)>();
	func<decltype(y2)>();
	return 0;
}

链接时得到的报错信息类似于:

a.cpp:(.text+0x26): undefined reference to `void func<int const&>()'
a.cpp:(.text+0x2b): undefined reference to `void func<int>()'
collect2: error: ld returned 1 exit status

使用在 C++14 中加入的 decltype(auto) 即可避免这个问题,decltype(auto) 会完全忠实于初始化表达式右侧的类型,如同将其带入 decltype 说明符一般:

template<typename T>
void func();

int main() {
	int y0 = 1;
	const int &y1 = y0;
	decltype(auto) y2 = y1;
	func<decltype(y2)>(); // undefined reference to `void func<int const&>()'
	return 0;
}

迭代器!

迭代器是一种广义化的指针,它使得 C++ 程序可以通过统一的方式处理不同的数据结构。迭代器库提供了迭代器的定义,同时还提供了迭代器特征、适配器及相关的工具函数。

因为迭代器是指针的抽象,所以它们的语义是 C++ 的指针的大多数语义的泛化。这确保指针能够用于所有接受迭代器的函数模板。

最基本的迭代器用法看起来是这样的:

#include <vector>
#include <cstdio>
 
int main() {
	std::vector<int> a{1, 2, 3, 4};
	for (auto it = a.begin(); it < a.end(); ++it) std::printf("%d\n", *it);
	return 0;
}

我们的代码并不需要修改这个 std::vector,因此可以使用不可变迭代器,这在需要遍历的数组是不可变时格外有用:

#include <vector>
#include <cstdio>
 
int main() {
	const std::vector<int> a{1, 2, 3, 4};
	for (auto it = a.cbegin(); it < a.cend(); ++it) std::printf("%d\n", *it);
	return 0;
}

使用范围 for 循环的语法糖,上面代码可以改写成这样:

#include <vector>
#include <cstdio>
 
int main() {
	std::vector<int> a{1, 2, 3, 4};
	for (int x : a) std::printf("%d\n", x);
	return 0;
}

如果需要使用范围 for 循环的语法糖的同时强调其不可变性,则可以使用 std::as_const

#include <vector>
#include <cstdio>

// std::as_const 需要 C++17,下面是一个可用的低版本替代实现
// template<typename T> struct add_const_t { using type = const T; };
// template<typename T> typename add_const_t<T>::type& as_const(T& t) noexcept { return t; }
// template<typename T> void as_const(const T&&) = delete;

using std::as_const;

int main() {
	std::vector<int> a{1, 2, 3, 4};
	for (int x : as_const(a)) std::printf("%d\n", x);
	return 0;
}

如果我们想要反向遍历容器,可以使用反向迭代器(可以使用 rcbegin()rcend 表示反向不可变迭代器):

#include <vector>
#include <cstdio>
 
int main() {
	std::vector<int> a{1, 2, 3, 4};
	for (auto it = a.rbegin(); it < a.rend(); ++it) std::printf("%d\n", *it);
	return 0;
}

通过上面初步认识,我们意识到迭代器和指针很相像。但是其比指针更加灵活,例如我们可以用迭代器遍历内部是树形结构而非顺序结构的 std::set

#include <set>
#include <cstdio>

int main() {
	std::set<int> a{1, 2, 3, 4};
	for (int x : a) std::printf("%d\n", x);
	return 0;
}

std::vector 允许随机访问,因此写出 a.begin() + 5 是合理的,时间复杂度为 \(O(1)\),但是 std::set 并不支持这一点,它只支持迭代器自增和自减,且时间复杂度为 \(O(\log n)\)。这说明,迭代器有不同的类型,有些可以支持更多的功能。

标准库中 std::advancestd::distancestd::nextstd::prev 几个函数提供了对迭代器的基本操作。具体而言,

  • std::advance:接受一个迭代器 it 和一个距离 d(可为负值)作为参数,将增加给定的迭代器 itd 个元素的步长;
  • std::distance:接受两个迭代器 firstlast 作为参数,返回从 firstlast 的路程(若 last 不可从 first 通过若干次自增 first 抵达,则行为未定义);
  • std::next:接受一个迭代器 it 和一个距离 n (默认值为 \(1\))作为参数,返回迭代器 it 的第 n 个后继;
  • std::prev:接受一个迭代器 it 和一个距离 n (默认值为 \(1\))作为参数,返回迭代器 it 的第 n 个前驱。

标准库中有大量的函数接受迭代器作为参数,例如我们耳熟能详的 std::sortstd::reversestd::uniquestd::shuffle 等。下面介绍几个使用迭代器的标准库函数。

std::fillstd:countstd::iota 三个函数接受两个迭代器 firstlast 以及一个值 value 作为其参数,它们的作用分别如下:

  • std::fill:赋值给定的 value 给范围 [first, last) 中的元素;
  • std::count:计数在范围范围 [first, last) 中等于 value 的元素;
  • std::iota:以始于 value 并重复地求值 ++value 的顺序递增值填充范围 [first, last)

std::copystd::copy_backwardstd::movestd::move_backward 四个函数接受三个迭代器 s_firsts_lastd_first 作为其参数,它们的作用分别如下:

  • std::copy:复制 [s_first, s_last) 所定义的范围中的元素到始于 d_first 的另一范围(若 d_first 包含于 [s_first, s_last) 则行为未定义);
  • std::copy_backward:从后向前复制 [s_first, s_last) 所定义的范围中的元素到始于 d_first 的另一范围;
  • std::move:移动 [s_first, s_last) 所定义的范围中的元素到始于 d_first 的另一范围(若 d_first 包含于 [s_first, s_last) 则行为未定义);
  • std::move_backward:从后向前移动 [s_first, s_last) 所定义的范围中的元素到始于 d_first 的另一范围;

std::fill_n 函数接受一个迭代器 first、一个长度 n 以及一个值 value 作为其参数,其作用是将给定的 value 赋给从 first 开始的范围的首 n 个元素,若 \(n\leq 0\) 则不做任何事。

std::copy_n 函数接受一个迭代器 first、一个长度 n 以及另一个迭代器 res 作为其参数,其作用是准确复制来自始于 first 的范围的 n 个值到始于 res 的范围,若 \(n\leq 0\) 则不做任何事。

除了用于访问元素的迭代器,一些容器还支持插入迭代器 std::inserter_iterator,下面是一个使用 std::back_inserter 的例子(需要容器支持 push_back 操作):

#include <vector>
#include <cstdio>

int main() {
	std::vector<int> a{1, 2, 3, 4};
	auto it = std::back_inserter(a);
	*it = 1;
	*it = 2;
	for (int x : a) {
		printf("%d ", x);
	}
	return 0;
}

上面的代码输出:

1 2 3 4 1 2 

搭配使用需要输出迭代器的函数会很方便:

#include <bits/stdc++.h>

int main() {
	std::vector<int> a{1, 2, 3, 4};
	std::fill_n(std::back_inserter(a), 3, 5);
	for (int x : a) {
		printf("%d ", x);
	}
	return 0;
}

上面的代码输出:

1 2 3 4 5 5 5  

类似的还有 std::front_inserter 用于在容器开头插入(需要支持 push_front)以及 std::inserter 用于在容器的特定位置插入(需要支持 insert),下面例子指出了 std::inserter 可以很好地搭配 std::set

#include <bits/stdc++.h>

int main() {
	std::set<int> a{1, 2, 3, 4};
	std::vector<int> b{7, 8, 9};
	std::copy(b.begin(), b.end(), std::inserter(a, a.end()));
	for (int x : a) {
		printf("%d ", x);
	}
	return 0;
}

上面的代码输出:

1 2 3 4 7 8 9  

输入输出迭代器(std::istream_iteratorstd::ostream_iterator)赋予了将输入输出流当做迭代器的能力:

#include <bits/stdc++.h>

int main() {
	std::istringstream in("5 1 2 3 4 5");
	int n;
	in >> n;
	std::vector<int> a;
	std::copy_n(std::istream_iterator<int>(in), n, std::back_inserter(a));
	std::copy(a.begin(), a.end(), std::ostream_iterator<int>(std::cout, " "));
	return 0;
}

上面的代码输出:

1 2 3 4 5  

也可以将上面代码的 in 改为 std::cin,这将改为由标准输入流输入数据。

函数!

函数作为在内存中的代码片段,我们可以取其地址存放在变量中,这个过程是隐含的:

#include <cstdio>

int add(int x, int y) {
	return x + y;
}

int main() {
	using add_type = int (*)(int, int);
	add_type a = add; // 隐式取址,等价于 &add
	std::printf("%d\n", a(1, 2));
	return 0;
}

当然,上面代码也可以直接使用 auto 而不必显式地定义 add_type 类型,这里只是为了说明一个函数指针的类型是:

返回类型 (*) (参数类型列表)

一个具有实践意义的例子是 std::sort 的比较函数:

#include <cstdio>
#include <algorithm>

bool cmp(int x, int y) {
	return x > y;
}

int main() {
	std::vector<int> a{1, 2, 3, 4};
	std::sort(a.begin(), a.end(), cmp);
	for (auto x : a) std::printf("%d\n", x);
	return 0;
}

我们给 std::sort 传入作为第三个参数的比较函数就是使用函数指针的方式传入的。当然,你可能知道标准库中的 std::greater,这将大幅简化代码:

#include <cstdio>
#include <functional>
#include <algorithm>

int main() {
	std::vector<int> a{1, 2, 3, 4};
	std::sort(a.begin(), a.end(), std::greater<>());	// C++ 14 允许省略可推导的模板参数
														// 在此之前则需要在尖括号中指明 int
	for (auto x : a) std::printf("%d\n", x);
	return 0;
}

那么 std::greater<>() 到底是什么?事实上,std::greater 是一个仿函数,它本质上是一个实现了 operator() 的结构体:

#include <cstdio>
#include <algorithm>

struct greater {
	bool operator()(int x, int y) {
		return x > y;
	}
};

int main() {
	std::vector<int> a{1, 2, 3, 4};
	std::sort(a.begin(), a.end(), greater());
	for (auto x : a) std::printf("%d\n", x);
	return 0;
}

在 C++11 中,我们迎来了一个新的语法(糖),使用 Lambda 表达式来创建一个“函数”:

#include <cstdio>
#include <algorithm>

int main() {
	std::vector<int> a{1, 2, 3, 4};
	std::sort(a.begin(), a.end(), [](int x, int y) -> bool {
		return x > y;
	});
	for (auto x : a) std::printf("%d\n", x);
	return 0;
}

你可能意识到了,Lambda 表达式本质上就是创建仿函数结构体的一个语法糖而已,因此即使两个 Lambda 拥有相同的返回类型和参数列表,它们的类型也不是相同的,下面代码验证了这一点:

#include <cstdio>

template<typename T, typename U>
struct is_same_helper {};

template<typename T>
struct is_same_helper<T, T> {
	using type = int;
};

template<typename T, typename U>
void _is_same(char x) {
	std::puts("not same!");
}

template<typename T, typename U>
void _is_same(typename is_same_helper<T, U>::type x) {
	std::puts("same!");
}

template<typename T, typename U>
void is_same() {
	_is_same<T, U>(0);
}

int main() {
	auto func1 = [](int, int) {};
	auto func2 = [](int, int) {};
	is_same<decltype(func1), decltype(func2)>(); // 输出 not same!
}

一般而言,Lambda 表达式可以省略其不需要的部分,下面均为合法的 lambda 表达式:

[] {}
[] (int x) {}
[sum = 0] (int x) mutable noexcept -> void {}

Lambda 表达式最开始的方括号是不可省略的,它被称为 Lambda 表达式的捕获列表,它可以以一个默认捕获符开始:

  • &(以引用隐式捕获被使用的自动变量);
  • =(以复制隐式捕获被使用的自动变量)。

随即是要捕获的变量的列表,若变量名以 & 作为前缀,则其是引用捕获的,否者是值捕获的。这两种捕获模式的区别在于:

  • 引用捕获可用于修改外部变量,而值捕获却不能实现此操作;
  • 引用捕获会反映外部变量的更新,而值捕获不会。
Lambda 默认不可变

Lambda 表达式的值捕获变量默认是不可变的,下面代码将产生编译错误:

int main() {
	int x = 1;
	auto func1 = [x] {
		x = 2;
	};
	return 0;
}

如果将 Lambda 修饰为 mutable,则允许修改这些变量的副本:

#include <cstdio>

int main() {
	int x = 1;
	auto func1 = [x]() mutable {
		x = 2;
		std::printf("%d\n", x);
	};
	func1();
	std::printf("%d\n", x);
	return 0;
}

上面代码输出为:

2
1
Lambda 与悬空引用

Lambda 表达式并不会延长所引用捕获的变量的声明周期:

#include <cstdio>

auto make() {
	int x = 1;
	return [&x](int y) {
		return x = x + y;
	};
}

int main() {
	auto func = make();
	printf("%d\n", func(1));
	return 0;
}

上面代码在 make 函数返回后,其中的 x 生命周期结束,但是其返回的 func 还保有对 x 的引用,这时这个引用变为悬空引用,调用 func 的行为是未定义的。

一个值得注意的例外是,如果 Lambda 使用了以引用捕获的引用,那么它使用原引用所指代的对象,而非被捕获的引用自身,因此下面代码是可行的:

> #include <cstdio>
 
auto make_function(int& x) {
    return [&]{ std::printf("%d\n", x); };
}
 
int main() {
    int i = 3;
    auto f = make_function(i); // f 中对 x 的使用直接绑定到 i
    i = 5;
    f(); // 输出 5
}

容易看出,Lambda 表达式的捕获列表,本质上是仿函数结构体中的成员变量,例如下面 Lambda 表达式:

#include <cstdio>

int main() {
	int x = 1, y = 2;
	auto func = [&x, y] {
		x += y;
	};
	func();
	std::printf("%d\n", x); // 输出 3
	return 0;
}

可以使用仿函数结构体重写为:

#include <cstdio>

int main() {
	int x = 1, y = 2;
	struct _lambda_func {
		int &x;
		const int y;
		_lambda_func(int &x, int y) : x(x), y(y) {}
		void operator()() {
			x += y;
		}
	} func(x, y);

	func();
	std::printf("%d\n", x); // 输出 3
	return 0;
}

在 C++14 后,我们可以进行带初始化器的捕获,即在捕获子句中引入和初始化新变量,而无需将这些变量声明于 Lambda 函数的封闭范围内。初始化可以任何任意表达式表示,它的行为如同它声明并显式捕获一个以类型 auto 声明的变量,该变量的声明区是 Lambda 表达式体,但:

  • 如果以复制捕获,那么闭包对象的非静态数据成员是指代这个 auto 变量的另一种方式。
  • 如果以引用捕获,那么引用变量的生存期在闭包对象的生存期结束时结束。

下面代码将输出 1

#include <cstdio>

int main() {
	int x = 1;
	auto func = [x = x]() {};
	std::printf("%d\n", func.x);
}

在 C++14 中,如果参数类型是泛型,则可以使用 auto 关键字作为类型说明符。 此关键字将告知编译器将函数调用运算符创建为模板。 参数列表中的每个 auto 实例等效于一个不同的类型参数。例如:

#include <cstdio>
#include <algorithm>

int main() {
	std::vector<int> a{1, 2, 3, 4};
	std::sort(a.begin(), a.end(), [](auto x, auto y) {
		return x > y;
	});
	for (auto x : a) std::printf("%d\n", x);
	return 0;
}

从现在开始,除非特殊说明,我们提及“函数”这个词实际上是指任何可调用的东西,包括函数指针,仿函数和 Lambda。为了方便起见,我们把返回类型为 bool 的函数称为谓词,只接受一个参数的函数称为一元函数,只接受一个参数的谓词被称为一元谓词。

下面介绍几个需要一元谓词的标准库函数:

std::all_ofstd::any_ofstd::none_ofstd::count_ifstd::find_ifstd::find_if_not 六个函数接受两个迭代器 firstlast 以及一个一元谓词 p 作为参数,它们的功能分别是:

  • std::all_of:检查一元谓词 p 是否对范围 [first, last) 中所有元素返回 true
  • std::any_of:检查一元谓词 p 是否对范围 [first, last) 中至少一个元素返回 true
  • std::none_of:检查一元谓词 p 是否不对范围 [first, last) 中任何元素返回 true
  • std::count_if:计数一元谓词 p 对范围 [first, last) 中多少个元素返回 true
  • std::find_if:返回一元谓词 p 对范围 [first, last) 中首个返回 true 的元素的迭代器,未找到则返回 last
  • std::find_if_not:返回一元谓词 p 对范围 [first, last) 中首个不返回 true 的元素的迭代器,未找到则返回 last

std::for_each 函数接受两个迭代器 firstlast 以及一个一元函数 f 作为参数,它按顺序应用给定的函数对象 f 到解引用范围 [first,last) 中每个迭代器,返回 f 的右值引用。特别地,如果迭代器类型是可变的,那么 f 可以通过解引用后的迭代器修改范围的元素。忽略 f 返回的结果。

我们可以使用 std::for_each 来将一个函数应用到一个序列的每个元素上,下面代码会把 a 中的每个元素翻倍:

#include <cstdio>
#include <vector>
#include <algorithm>
 
int main() {
	std::vector<int> a{1, 2, 3, 4};
	std::for_each(a.begin(), a.end(), [](int &x) {
		x *= 2;
	});
	for (int x : a) std::printf("%d\n", x);
	return 0;
}

由于 std::for_each 返回 f 的右值引用,结合带有有初始化器的捕获的 Lambda,可以完成一些复杂的统计,下面代码计算 a 中元素的平方和:

#include <cstdio>
#include <vector>
#include <algorithm>
 
int main() {
	std::vector<int> a{1, 2, 3, 4};
	std::printf("%d\n", std::for_each(a.begin(), a.end(), [sum = 0](int &x) mutable {
		sum += x * x;
	}).sum);
	return 0;
}

std::transform 函数接受三个迭代器 s_firsts_lastd_first 以及一个一元函数 f 作为参数,应用给定的函数到范围 [s_first, s_last) 并将结果存储到始于 d_first 的范围。下面代码将字符串 a 转化为大写字母:

#include <bits/stdc++.h>
 
int main() {
	std::string a{"hello!"};
	std::transform(a.cbegin(), a.cend(), a.begin(), [](auto x) { return std::toupper(x); });
	std::printf("%s\n", a.c_str());
	return 0;
}

std::copy_if 函数接受三个迭代器 s_firsts_lastd_first 以及一个一元谓词 p 作为参数,复制范围 [s_first, s_last) 中的所有谓词 p 返回 true 的元素到始于 d_first 的范围。下面代码将输出 a 中所有的偶数:

#include <bits/stdc++.h>

int main() {
	std::vector<int> a{1, 2, 3, 4};
	std::copy_if(a.begin(), a.end(), std::ostream_iterator<int>(std::cout, " "), [](int x) {
		return x % 2 == 0;
	});
	return 0;
}

std::generate 函数接受两个迭代器 firstlast 以及一个无参数的函数 f 作为参数,以给定函数对象 f 所生成的值赋值范围 [first, last) 中的每个元素。

std::generate_n 函数接受一个迭代器 first 和长度 n 以及一个无参数的函数 f 作为参数,赋值给定函数对象 f 所生成的值给始于 first 的范围的首 n 个元素,若 \(n\leq0\) 则不做任何事。下面代码将向标准输出流输出 5 个随机数:

#include <bits/stdc++.h>

int main() {
	std::mt19937 rng(std::random_device{}());
	std::generate_n(std::ostream_iterator<int>(std::cout, " "), 5, std::ref(rng));
	return 0;
}
posted @ 2023-07-17 21:24  方而静  阅读(120)  评论(0编辑  收藏  举报