跳转至

20 漫游C++23:更好的C++20

你好,我是卢誉声。

到现在为止,我们应该已经发现,C++20这个版本的规模和C++11等同,甚至更加庞大。一方面,它变得更加现代、健壮和安全。另一方面,自然也存在很多不足之处。

因此,就像C++14/17改进、修复C++11那样,C++23必然会进一步改进C++20中“遗留”的问题。令人高兴的是,C++23标准已经于22年特性冻结。除了作为C++20的补丁,它还引入了大量新特性(主要是标准库部分),成为了一个至少中等规模的版本更新。

既然C++23的特性已经冻结,年底发布的正式标准最多只是标准文本的细节差异,现在正是一个了解C++23主要变更的好时机。

给你一点提示,现阶段各个编译器尚未针对C++23提供完善的支持。因此,对于这一讲涉及的代码,主要是讲解性质,暂时无法保证能够编译执行。

接下来,就让我们从语言特性变更、标准库变更两个角度,开始漫游C++23吧。

语言特性变更

C++23的语言特性变更真的不多,不过即使如此,也有一些非常亮眼的特性变更,比如我们即将了解的几个新特性。

显式this参数

要明白这个语言特性变更,得先弄清楚什么是显式this参数。

让我们来看一下这段代码。

#include <iostream>
#include <cstdint>
 
class Counter {
public:
    Counter(int32_t count) : _count(count) {}
    
    Counter& increment(this Counter& self) {
        self._count++;
 
        return self;
    }
 
    template <typename Self>
    auto& getCount(this Self& self) {
        return self._count;
    }
 
private:
    int32_t _count;
};
 
int main() {
    Counter counter(10);
    std::cout << counter.getCount() << std::endl;
 
    counter.increment().increment();
    std::cout << counter.getCount() << std::endl;
 
    const Counter& counterRef = counter;
    std::cout << counterRef.getCount() << std::endl;
 
    return 0;
}

诶?!看完代码你是不是有些奇怪,为什么this关键字会出现在上面的函数参数列表中?其实,这就是所谓的显式this。现在我就来解释一下。

我们第一次学习C++和Java时可能有一个疑问,为什么访问成员变量或成员函数时会出现一个没有定义过的指针—— this。众所周知,在一门强类型静态语言中任何符号都需要提前定义,那么这个this是什么?又是从哪里来的呢?

没错,这的确是一个C++引入的和自身哲学非常不匹配的特性,而且持续至今,也进一步影响了Java、C#和JavaScript等现代语言(当然JS的this更加诡异),影响不可谓不大。

但在同样的一些语言中——比如Python,成员方法必须要在函数列表中显式写出来,这也就是C++中引入显式this参数的目的,让所有的使用到的符号都需要提前定义。比如前面代码里的第8到12行,如果在C++23之前,我们应该这样写。

Counter& increment() {
  _count++;

  return *this;
}

看完之后,你可能更加疑惑了,这样不是让代码变得更复杂了吗?毕竟我们都已经习惯了使用隐式的this。

那么从前面代码14到17行,我们就可以看到显式this的价值了——通过模板让代码变得更简单。这段代码这样写的目的是替代以前的传统写法,后面是它的等价实现。

const int32_t& getCount() const {
  return _count;
}

int32_t& getCount() {
  return _count;
}

我们在需要返回内部引用时,即使代码的实现一模一样,也经常需要定义一个const版本和非const版本。这种情况下,显式this的确帮助我们解决了一个问题——因为模板函数可以根据传入的参数自动匹配参数类型。

同时,使用显式this还可以实现递归Lambda函数。我们还是结合代码来理解。

#include <iostream>
#include <cstdint>

int main(){
  auto fibonacci = [](this auto self, int32_t value) {
    if (value == 0) {
      return 0;
    }

    if (value <= 2) {
      return 1;
    }

    return self(value-1) + self(value-2);
  };

  auto result = fibonacci(10);
  std::cout << result << std::endl;

  return 0;
}

可以看到,显式this让原本很多繁琐的工作变得更加简单了。相信你现在也体会到了,这是一个重要的特性变更。

多元operator[]

多元operator是为了支持标准中引入的多维数组类型而提出的,比如后面这段示例,就采用了多元operator[]实现多维数组。

#include <iostream>
#include <cstdint>
#include <vector>
#include <initializer_list>
#include <concepts>
#include <ranges>
#include <algorithm>
#include <format>
 
namespace views = std::views;
namespace ranges = std::ranges;
 
template <typename Element>
class MArray {
public:
    MArray(const std::initializer_list<std::size_t>& dims): _dims(dims), _size(0) {
        if (!dims.size()) {
            return;
        }
 
        std::size_t prevDimSize = 1;
        std::vector<std::size_t> dimSizes;
 
        for (auto dim : views::reverse(_dims)) {
            dimSizes.push_back(prevDimSize);
            prevDimSize *= dim;
        }
 
        ranges::copy(views::reverse(dimSizes), std::back_inserter(_dimSizes));
 
        _size = prevDimSize;
        _elements.resize(_size);
    }
 
    template <typename Self, std::integral... Indexes>
    auto& operator[](this Self& self, Indexes... remainingIndexes) {
        std::size_t acutalIndex = self.calcIndex(0, remainingIndexes...);
 
        return self._elements[acutalIndex];
    }
 
    template <std::integral Index>
    std::size_t calcIndex(std::size_t dimIndex, Index firstIndex) {
        return static_cast<std::size_t>(firstIndex) * _dimSizes[dimIndex];
    }
 
    template <std::integral Index, std::integral... Indexes>
    std::size_t calcIndex(std::size_t dimIndex, Index currentIndex, Indexes... remainingIndexes) {
        return static_cast<std::size_t>(currentIndex) * _dimSizes[dimIndex] + calcIndex(dimIndex + 1, remainingIndexes...);
    }
 
    std::size_t size() const {
        return _size;
    }
 
    std::vector<Element>& elements() {
        return _elements;
    }
 
private:
    std::vector<Element> _elements;
    std::vector<std::size_t> _dims;
    std::vector<std::size_t> _dimSizes;
    std::size_t _size;
};
 
int main() {
    MArray<int32_t> array {2, 3, 4, 5};
 
    auto& elements = array.elements();
    for (std::size_t index = 0; index != array.size(); ++index) {
        elements[index] = static_cast<int32_t>(index * 2);
    }
 
    auto a1 = array[1];
    std::cout << a1 << std::endl;
 
    auto a2 = array[1, 2];
    std::cout << a2 << std::endl;
 
    auto a3 = array[1, 2, 3];
    std::cout << a3 << std::endl;
 
    auto a4 = array[1, 2, 3, 4];
    std::cout << a4 << std::endl;
 
    return 0;
}

这段代码其他部分很好理解,你可以重点留意代码35到40行,就是一个多参数的operator[]定义。与以往不同,C++23中operator可以定义任意数量的参数,我们同时使用了显式this简化了对const的处理。因此,我们可以像75—85行一样通过[]访问某个元素。

这对访问多维数组来说极为方便,语法也就和Python的NumPy中下标访问更像了,在数值计算和向量计算中会有大量应用。

标准库特性变更

相比于语言特性变更,C++23中更多的是标准库级别的变更,接下来,我会带你深入了解几个重要的标准库特性变更。

标准模块:std与std.compact

第一个必须提及的变更就是std这个标准Module。

C++ Module是C++20中引入的最为重要的特性,但我们也知道,C++20中的标准库并非设计成简单的Module,而且不同编译器的支持完善程度也相差甚多(比如gcc模块嵌套层次深了之后标准库的import就失效了),这导致在Module中使用标准库并不是很方便,不过标准对此不做强制要求。

但在C++23中就明确设定了名为std的模块,该模块会将标准库中所有的符号全部引入当前模块!

这样一来,在使用标准库的时候就会非常方便了,我们终于再也不用去记忆,什么函数在什么库中了。同时,所有继承自C标准库的符号,都被放到了std.compact模块中,在使用std::memcpy等函数的时候就需要import这个模块。

虽然直接引入std和std.compact会导入很多的模块,但因为编译器实现一定会针对这些标准库模块提供二进制缓存。其实,最后的编译速度,可能比现在#include某个标准库头文件还快得多。

因此,在C++23中使用C++ Module会惬意不少——当然,前提是编译器能够提供良好支持。

expected与异常处理

在了解C++23 Expected前,我们先看一下传统C++的异常处理方式。一般来说有两种,你可以参考后面的表格。

可以看出传统的两种异常处理方式,不难发现它们的缺点都非常明显,比如错误码导致代码冗余,容易忽略掉错误处理,而通过try/catch处理异常又有致命的性能和资源管理问题(甚至导致Google不提倡采用try/catch),那么是否有更现代化的异常处理方式呢?

C++23终于仿照Rust等语言,提出了expected类型,并通过Monadic interfaces实现新的异常处理风格。后面这段代码,就演示了如何使用expected处理异常。

#include <iostream>
#include <cstdint>
#include <expected>
#include <fstream>
#include <vector>
#include <string>
#include <filesystem>
#include <numeric>
 
namespace fs = std::filesystem;
 
std::expected<std::vector<std::string>, std::errc> readListFile(const std::string& filePath);
std::expected<std::vector<std::uintmax_t>, std::errc> getFileSizes(const std::vector<std::string>& fileList);
std::expected<std::uintmax_t, std::errc> sumFileSize(const std::vector<std::uintmax_t>& fileSizes);
 
int main() {
    auto result = readListFile("ObjectList.txt")
        .and_then(getFileSizes)
        .and_then(sumFileSize)
        .or_else([](auto e) {
            std::cout << "Error!" << std::endl;
 
            return std::unexpected(e);
        });
    
    if (result) {
        std::cout << "Result: " << result << std::endl;
    }
 
    return 0;
}
 
std::expected<std::vector<std::string>, std::errc> readListFile(const std::string& filePath) {
    std::ifstream inputFile(filePath.c_str());
 
    if (!inputFile.is_open()) {
        return std::unexpected(std::errc::io_error);
    }
 
    std::vector<std::string> lines;
    while (inputFile) {
        std::string line;
        if (std::getline(inputFile, line)) {
            lines.push_back(line);
        }
    }
 
    return lines;
}
 
std::expected<std::vector<std::uintmax_t>, std::errc> getFileSizes(const std::vector<std::string>& fileList) {
    std::vector<std::uintmax_t> fileSizes;
    for (const auto& filePath : fileList) {
        if (!fs::exists(filePath)) {
            return std::unexpected(std::errc::no_such_file_or_directory);
        }
 
        fileSizes.push_back(fs::file_size(filePath));
    }
 
    return fileSizes;
}
 
std::expected<std::uintmax_t, std::errc> sumFileSize(const std::vector<std::uintmax_t>& fileSizes) {
    return std::accumulate(fileSizes.begin(), fileSizes.end(), static_cast<std::uintmax_t>(0u));
}

看完代码我们发现,所有函数的返回类型都变成了expected,这个类型类似于optional,是一个模板类。它包含两个模板参数,一个是正常情况的返回值类型,另一个是错误码类型。

错误码类型类似于optional,包含一个成员函数has_value用于判断对象是否包含正常的返回值,只不过约定了第二个类型作为错误码。

另外,expected类型还提供了and_then与or_else成员函数。

成员函数and_then的参数是下一个处理函数,该函数一般也会返回expected类型。如果expected对象正常返回,那么就会调用and_then,否则调用or_else。

此外,expected的and_then可以像17到24行这样链式调用,执行多个业务逻辑时会非常有用。最后的结果包含正常的值,就可以调用value成员函数获取内部包含的值(如代码27行)。

这种风格的异常处理类似于Rust等现代语言。其优点是异常处理逻辑清晰,可以将异常处理集中在调用链的某些节点,实现业务处理和异常处理关注点分离。

缺点是缺乏处理分支逻辑的手段,同时因为and_then等函数采用模板函数实现,会造成生成代码膨胀等问题。但无论如何,我们的确有了一种现代化的新异常处理手段,在数据流的处理场景非常实用。

Ranges扩展

Ranges是C++20的一大特性,但通过前面课程的学习,我们也发现了使用Ranges的一些问题。

1.适配器依然不够丰富。

2.只有标准库内的适配器闭包对象支持通过视图管道连接,开发者自定义的符合适配器闭包对象的类型无法支持视图管道连接,需要采用变通的方案。

3.无法将ranges简单转换成某种类型的STL容器。

C++标准委员会也非常清楚这些问题,因此在C++23中对Ranges补充了大量支持。

首先Ranges增加了大量适配器,我把它们用表格的方式做了梳理,供你参考。

有了这些适配器,可以让我们的编码变得更加便捷。接着,C++23允许使用视图管道,连接自定义的符合适配器闭包对象的类型,不需要再通过变通方法来实现。

最后,C++23提供了一个非常实用的转换函数to,它允许我们将视图转换成任意类型的标准容器,这其中包括多层嵌套的range对象。比如说,下面这段代码里,我们就将range转换成了vector数组。

#include <iostream>
#include <vector>
#include <string>
#include <ranges>
#include <cstdint>
 
class Article {
public:
    std::string title;
    std::vector<std::string> paragraphs;
};
 
std::vector<Article> getArticles();
 
namespace views = std::views;
namespace ranges = std::ranges;
 
int main() {
    auto paragraphCounts = getArticles() |
        // 筛选多于3个段落的文章
        views::filter([](const auto& article) { return article.paragraphs.size() >= 3; }) |
        // 将文章转换为段落
        views::transform([](const auto& article) { return article.paragraphs | views::take(3); }) |
        // 统计段落长度
        views::transform([](const auto& paragraphs) {
            return paragraphs | views::transform(
                [](const std::string& paragraph) {
                    return paragraph.size();
                }
            ) | ranges::to<std::vector>();
        }) 
        // 转换为vector
        | ranges::to<std::vector>()
        // 使用join合并
        | views::join;
 
    for (const auto& paragraphCount : paragraphCounts) {
        std::cout << paragraphCount << std::endl;
    }
 
    return 0;
}

在这段代码中,我们只需要指定类型,to会帮助我们完成繁杂的转换工作。而且由于to本身可以是一个适配器闭包对象,因此可以使用视图管道连接,使得代码更加赏心悦目。

多维数组视图

事实上,C++标准库一直在解决有关数组的各类问题。比如,在C++11中引入了静态长度的std::array。再比如,在C++20中引入了span作为一维数组视图,支持动态长度。在不使用std::array的时候,我们也能引用数组并存储数组的长度信息,完成边界检查。

为了便于理解,我写了一段代码,我们先来看看。

#include <iostream>
#include <span>
#include <cstdint>
 
int main() {
    int32_t array1[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    std::span<int32_t> span1 = std::span(array1);
    std::span<int32_t, 10> span2 = std::span(array1);
    // 等同于std::span<int, 10>
    auto span3 = std::span(array1);
 
    // 如果模板参数不包含长度,只能在运行时获取长度
    //static_assert(span1.size() == 10);
    std::cout << span1.size() << std::endl;
    // 如果在模板参数指定长度,可以在编译器获取长度
    static_assert(span2.size() == 10);
    static_assert(span3.size() == 10);
 
    return 0;
}

相较于C++11和C++20,C++23引入了mdspan作为多维数组视图。那么,我们为什么需要多维数组视图呢?

这是因为,C++中的多维数组支持一直不够完善,比如不支持边界检查,只能通过C99的VLA语法指定第一维长度,后续维度无法动态扩展等等。如果使用多层指针(比如int***),则需要自己循环创建每一层的动态数组,还会遇到释放内存的问题。为此,我们甚至不得不经常使用一维数组来模拟多维数组,比如下面这段代码。

#include <iostream>
#include <cstdint>
 
int main() {
    int32_t array1[]{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
 
    for (std::size_t row = 0; row != 2; ++row) {
        for (std::size_t col = 0; col != 5; ++col) {
            // 可以通过C++23的多元operator[]自动计算索引访问到元素
            std::cout << array1[row * 5 + col] << " ";
        }
        std::cout << std::endl;
    }
 
    return 0;
}

C++23提出了mdspan后,就不需要我们自己手动通过一维数组模拟多维数组了。

后面这段代码,演示了如何使用mdspan包装一维数组来模拟二维数组,你可以结合代码体会一下。

#include <iostream>
#include <span>
#include <mdspan>
#include <cstdint>
 
int main() {
    int32_t array1[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
 
    // mdspan可以设定多个维度,相当于array[2][5]
    auto mdspan1 = std::mdspan(array1, 2, 5);
    for (std::size_t row = 0; row != 2; ++row) {
        for (std::size_t col = 0; col != 5; ++col) {
            // 可以通过C++23的多元operator[]自动计算索引访问到元素
            std::cout << mdspan1[row, col] << " ";
        }
        std::cout << std::endl;
    }
 
    return 0;
}

其实C++23的mdspan还具备更多的特性,包括设置动态长度,修改数组内存布局(也就是元素不一定需要连续存储),也支持控制元素访问(比如如何检查边界,支持原子访问)。

print与format

在C++20中引入的format虽然只是一个库特性,但是它对解决C++20之前的文本格式化问题很有帮助。

不过,format只能将文本格式化到字符串中,这导致在实际输出时,我们还是需要通过C++的输出流,来输出字符串——这有些不方便。对于很多其他的高级编程语言来说,它们基本都已经支持直接在输出函数中进行文本格式化了,这显得C++实在有些不够“现代”。

不过在C++23中,终于引入了print和println,接口基本和Rust的print/println函数一样,代码如下所示。

#include <print>
#include <cstdint>
#include <iostream>
 
int main() {
    std::print("{}: {}\n", "Name", "S1");
    std::println("{}: {}", "Name", "S2");
    std::println(std::cerr, "{}: {}", "Name", "S3");
 
    return 0;
}

这个函数的本质是调用format生成文本,然后将文本直接输出到输出流中。print和println默认会将文本输出到标准输出流std::cout中,我们可以将其修改为其他的输出流,比如std:cerr或者ofstream等其他的输出流对象中。

虽然这个特性看起来很简单,但我觉得还是有必要专门了解一下。这是因为,这从一定程度上会改变我们输出内容的习惯——也许在十年之后,我们就很少能在新的C++代码中看到使用 << 输出文本的行为了。

堆栈跟踪

最后一个特性,是C++23提供的堆栈跟踪库——它终于来了!

C++从一开始就提出了“异常”,这是一种替代C语言错误码的异常处理机制。但遗憾的是,C++的异常处理能力其实一直有很多缺憾,包括后面这三个问题。

1.如果顶层没有try/catch程序会直接崩溃,可能无法获知任何的异常信息。

2.在catch中无法通过exception获取抛出异常处的调用堆栈。

3.没有提供现代语言的finally等特性,需要利用C++的RAII机制实现类似行为,还是有点不便。

C++20中提出的source_location可以帮助我们部分解决第二个问题:抛出异常的函数可以在异常中包装source_location对象,这样catch时就可以获取到抛出异常的位置。

不过问题在于,source_location只包含抛出异常所在点的信息,无法获取调用堆栈信息,了解程序是通过什么路径调用到函数的。

因此C++23中终于提供了stacktrace库,补充了source_location不足之处。因此,现在我们可以像下面的代码一样抛出异常并catch异常。

#include <stacktrace>
#include <iostream>
#include <vector>
#include <cstdint>
#include <format>
 
class StacktraceException : public std::exception {
public:
    // 通过默认构造函数获取创建异常时的堆栈
    StacktraceException(const char* message, std::stacktrace stacktrace = std::stacktrace::current()) :
        std::exception(message), _stacktrace(stacktrace) {}
 
    const std::stacktrace& getStacktrace() const {
        return _stacktrace;
    }
 
private:
    std::stacktrace _stacktrace;
};
 
int32_t visitVector(const std::vector<int32_t>& values, std::size_t index) {
    // 如果索引越界那么抛出异常
    if (index >= values.size()) {
        throw StacktraceException("out_of_range");
    }
 
    return values[index];
}
 
int main() {
    try {
        std::vector<int32_t> values{ 1, 2, 3 };
        std::cout << visitVector(values, 1) << std::endl;
        std::cout << visitVector(values, 3) << std::endl;
    }
    catch (const StacktraceException& e) {
        std::cerr << std::format("Error: {}\n", e.what()) << std::endl;
 
        // 通过标准输出流直接输出堆栈(标准格式,由标准库实现自行决定)
        std::cerr << "Standard Stacktrace: \n" << e.getStacktrace() << std::endl;
        std::cerr << "Custom Stacktrace: \n";
 
        // 自定义输出格式
        std::size_t index = 0;
        for (const auto& stacktraceEntry : e.getStacktrace()) {
            std::cerr << std::format(
                "{}. {}:{} -> {}",
                index,
                stacktraceEntry.source_file(),
                stacktraceEntry.source_line(),
                stacktraceEntry.description()
            ) << std::endl;
        }
    }
    catch (...) {
        const auto& e = std::current_exception();
        std::cerr << "Unexpected exception" << std::endl;
    }
 
    return 0;
}

在这段代码中,我们可以看出,stacktrace类似于source_location,必须我们自己手动构造。stacktrace可以视为stacktrace_entry的一个序列,我们不仅可以通过标准输出流输出stacktrace,也可以通过stacktrace_entry的成员函数获取到我们想要获取的信息。

不过现在我们必须手动构造一个exception类,包装stacktrace_entry,还无法在标准的exception中获取堆栈。如果想要直接通过exception获取调用堆栈,可能要等到C++26中的补充了(已有相关提案)。

如果C++26能够完善这一点,那么C++的异常处理,大概就真的满足一个现代编程语言应该具备的特性了。

总结

今天这一讲,我带你漫游了C++23标准,并从语言特性和标准库特性两个方面介绍了C++23中比较重要的一些变化。

C++23重要的语言特性变更乏善可陈,我们着重学习了会给编码习惯带来较大变化的“显式this参数”和“多元operator[]”,还了解了它们的使用场景。

我们按重要程度,梳理一下C++23中的重要库的变更,包括以下几类。

  • 极为重大的变更:标准的std与std.compact模块。
  • 重要的变更:expected、多维数组视图、print、堆栈跟踪。
  • 对C++20的补充:Ranges扩展。

由于C++23标准23年底才会正式发布,因此现有编译器对C++23尚未提供完善的支持。现在你可以先做了解,在接下来的2到3年,我们或许就能用上较为稳定的、支持C++23的编译器,享受C++23带来的改变了,让我们拭目以待。

课后思考

在C++23的标准固化后,具体细节被总结在了这里。那么,请你阅读浏览一下这篇文章,分享你所期待的C++23特性吧!

欢迎说出你的看法,与大家一起分享。我们一同交流。下一讲见!