有人说你永远不能自称精通 C++,本文试图为这个观点提供一个例证。下面列出了一些从去年(2024)开始我编写 C++ 代码时犯过的错误。当然,有些可能看上去很蠢,不过谁又能在未知全貌的时候保证自己不会犯错呢?我认为这些错误至少初看上去是反直觉的。

std 集合操作只能用于有序容器

你要表示两个整数集合,所以你用了 std::unordered_set<int>。之后你想要求两个集合的交集,你搜了一下 STL,发现 std::set_intersection 似乎正合适。于是你写了一个简单的程序

// test_set.cpp
std::unordered_set<int> set1 = {1, 2, 3, 4, 5};
std::unordered_set<int> set2 = {4, 5, 6, 7, 8};

std::unordered_set<int> result;
std::set_intersection(set1.begin(), set1.end(), set2.begin(), set2.end(),
                        std::inserter(result, result.begin()));
for (const auto &elem : result) {
    std::cout << elem << " ";
}
std::cout << std::endl;

然后运行,你期望输出的结果是 4 5 或是 5 4(毕竟你很严谨)。可是实际结果呢

$ clang++ test_set.cpp -o test_set
$ ./test_set

什么都没有输出。

正如 std::set 实际上表示的是有序集合一样,std::set_intersection 实际上也是 ordered_set_intersection,只不过函数签名并不告诉你。

不过既然 std::set_intersection 已经定义在 <algorithm> 中了,那么也就不要期望其能有什么超出算法之外的魔法了。对于 std::unordered_set,还是老老实实用最笨的方法吧。

std::unordered_set<int> result;
for (const auto& elem : set1) {
    if (set2.find(elem) != set2.end()) {
        result.push_back(elem);
    }
}

没有魔法,但是模板还是有的。std 集合操作可以用于有序迭代器。

std::set 作为优先队列丢失元素

你需要这样一个数据结构,希望其内部有序,能够从头尾两端分别取出最大最小的元素,且能够随时插入新的元素。你很懒,所以没有想着自己实现一个数据结构。从 STL 里找来找去,你找到了 std::set,想着这是个好东西,从中取出元素和插入元素的时间复杂度都是 O(logn)。

你用 std::set 写了一小段代码

// test_priority_set.cpp
using Entry = std::pair<int, std::string>;
auto cmp = [](const auto &a, const auto &b) { return a.first < b.first; };

std::set<Entry, decltype(cmp)> pq(cmp);
pq.emplace(3, "Task 1");
pq.emplace(1, "Task 2");
pq.emplace(1, "Task 3");
pq.emplace(2, "Task 4");

while (!pq.empty()) {
    auto it = pq.begin();
    const auto &[_, task] = *it;
    std::cout << task << std::endl;
    pq.erase(it);
}

运行一下呢

$ ./test_priority_set
Task 2
Task 4
Task 1

Task 3 去哪了?

然后你才猛然想起来 std::set 并不是队列,而是集合。事后诸葛来看这个问题并不难发现,不过当我遇到这个问题时,它正藏在一个复杂的算法之中。

修复的方式很显然,用 std::multiset 代替 std::set

引用并非对象

引用并非对象。对象是什么呢?对象是存储在一块内存区域中的东西。

当然,从实现的角度来看,引用总是需要空间来存储的,它和指针没有本质上的区别。但是在 C++ 的语义中,引用并不是对象。你没法访问引用本身,而只能访问引用所引用的对象。

这就意味着你没法存储一个引用。比如说 std::vector<int&> 就是非法的。

如果你实在想要存引用的话要怎么办?那当然是用指针了。当然,容器通常是所有权转移的好地方,所以一定要注意引用的生命周期。

除了用指针,还可以用 std::reference_wrapper。比如说 std::vector<std::reference_wrapper<int>>。但是在应用程序的代码中使用这个并没有意义,因为这个类型只有 get() 方法,用于获取对应指针。

std::reference_wrapper 是给模板用的,其目的是避免所有权的转移。

比如说我们编写了一个用于构建闭包的函数

template <typename Fn, typename... Captured>
auto build_closure(Fn &&fn, Captured &&...captured) {
    return [fn = std::forward<Fn>(fn),
            ... captured = std::forward<Captured>(captured)](auto &&...args) mutable {
        fn(captured..., std::forward<decltype(args)>(args)...);
    };
}

之后我们编写一个函数 func,该函数传入一个非 const 引用,并在函数中修改传入参数的取值。我们将该函数和其参数构建为一个闭包并调用。

void func(int& v) { v += 1; }

int a = 1;
auto closure = build_closure(func, a);
closure(); // a = 1

按照直觉,调用之后 a 的取值应该是 2。可实际上其取值依然为 1。因为在 build_closure 中进行了 int 的复制构造。closure 中的 a 和原来的 a 并不是同一个对象。

因此这里需要使用 std::ref 来把 a 包装成一个 std::reference_wrapper。此时 build_closure 中将进行 std::reference_wrapper<int> 的构造。

int a = 1;
auto closure = build_closure(func, std::ref(a));
closure(); // a = 2

问题是这里的 std::reference_wrapper 和指针有什么区别呢?

如果我们选择传递指针,编译器将会报错。因为不存在从 int *int & 的隐式转换。在不使用 std::reference_wrapper 的情况下,只有修改函数签名,接受 int * 才能实现引用的传递。

int a = 1;
// int& b = &a; // Wrong
int &c = std::ref(a); // Right

std::vector<move_only> 是 copy constructible 的

你刚开始学习模板,所以想写一个简单的模板类。类似下面这样

template <typename T> struct Wrapper {
    T value;
};

你当然不满足于此,你想再定义一个 Wrapper<T>::create 函数。你吧这个函数的签名定义成如下这样

template <typename T> struct Wrapper {
    T value;

    static Wrapper<T> create(T v) {
        // ...
    }
};

然后你就会发现一个问题,T 可能是 move-only 的,也可能是 copy-only 的。你没法确定。不过你很快就找到了解决这一问题的魔法。那就是 <type_traits> 头文件。你使用 std::is_copy_constructible 来确定类型 T 是否是可复制的。如果是,那么使用复制语义,否则使用移动语义。

template <typename T> struct Wrapper {
    T value;

    static Wrapper<T> create(T v) {
        if constexpr (std::is_copy_constructible<T>::value) {
            return {v};
        } else {
            return {std::move(v)};
        }
    }
};

你试了几个例子

auto a = Wrapper<int>::create(42);
auto b = Wrapper<std::unique_ptr<int>>::create(std::make_unique<int>(42));
auto c = Wrapper<std::vector<int>>::create(std::vector<int>{});

嗯,都能完美编译。但是下面这个呢

auto d = Wrapper<std::vector<std::unique_ptr<int>>>::create(std::vector<std::unique_ptr<int>>{});

嗯?为什么会报错?

从报错信息来看,你会发现 std::vector<std::unique_ptr<int>> 竟然被 std::is_copy_constructible 判定为真!?

具体原理我也不太清楚,总之不要信任 std::is_copy_constructible 的判定结果。

这里有答案,但是我不太想看。

至于 Wrapper<T>::create 的实现,你应该使用通用引用。

template <typename T> struct Wrapper {
    T value;

    template <typename U = T> static Wrapper<T> create(U &&v) {
        return {std::forward<U>(v)};
    }
};

std::future::wait 不会转发异常

你有一个异步的任务

std::future<void> fut = std::async(std::launch::async, task);

你希望用如下操作令其退出。似乎没有问题,是吧?

stop_flag = true
auto status = fut.wait_for(std::chrono::seconds(1));
assert(status != std::future_status::deferred);
if (status == std::future_status::timeout) {
    std::cerr << "timeout" << std::endl;
    std::terminate();
}

当然,在正常的代码路径下这样做当然没有问题。但是如果 task 将会抛出一个异常呢?

例如,你的 task 函数在实现上存在问题。就像是下面这样

void task() {
    while (!stop_flag) {
        std::stoi("not a number");
    }
}

会抛出 std::invalid_argument

在测试的时候,你肯定希望代码在运行时不会抛出任何未处理的异常。你的测试代码(如果有的话)可能有类似下面的内容

try {
    stop_flag = true
    auto status = fut.wait_for(std::chrono::seconds(1));
    assert(status != std::future_status::deferred);
    if (status == std::future_status::timeout) {
        std::cerr << "timeout" << std::endl;
        std::terminate();
    }
} catch (const std::exception& e) {
    assert(false);
}

但是实际运行时,你会发现上述代码并不会抛出异常。

你可能需要在设置 stop_flag 和进行 wait 之间增加一段时间的等待。

这可是个问题。当然,是你没有认真阅读文档的问题。(不过谁又能一直认真呢?)

std::future::wait* 的语义是等待,直到该 future 完成。除此之外并不会做任何操作,比如访问 future 的取值,或者抛出导致 future 完成的异常。

完成 wait 所没有做的操作的方案是 std::future::get。这里需要注意 std::future<void> 的特化。其 get 函数不会返回取值,但如果 future 中包含一个异常,则会将该异常抛出。

有意思的是 get 的语义不止包括获取 future 的取值,还包括一直阻塞,直到 future 完成。

未完待续

就是未完待续