C++ 中文周刊 2025-12-12 第191期

周刊项目地址

公众号

点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了

qq群 753792291 答疑在这里

RSS

欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言

资讯

标准委员会动态/ide/编译器信息放在这里

编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期

性能周刊

文章

std::move doesn’t move anything: A deep dive into Value Categories

这篇文章深入讲解了C++的值类别(lvalue、prvalue、xvalue)和std::move的实质。

值类别概念:

C++11之前只有lvalue和rvalue,C++11引入了移动语义后,值类别变成了三种:

std::move的真相:

std::move实际上只是一个cast,它把lvalue转成xvalue,允许编译器调用移动构造函数。实现非常简单:

template<typename T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(t);
}

关键示例 - std::move不保证移动:

struct A {
    std::string s;

    A(std::string str) : s(std::move(str)) {
        // str被move后,依然是有效的lvalue
        std::cout << str.length() << '\n';  // 打印0,已被移走
    }
};

常见陷阱:

void process(const std::string& s) {
    std::string local = std::move(s);  // 没用!const对象不能移动
}

std::string get_string() {
    std::string s = "hello";
    return std::move(s);  // 画蛇添足!阻止了NRVO优化
}

std::move只是改变值类别的工具,真正的移动发生在移动构造/赋值函数里。return语句不要用std::move,会妨碍NRVO。

A faster full-range Leap Year function

作者发明了一个新的闰年判断算法,比传统方法快。

传统算法:

bool is_leap_year(int year) {
    return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}

需要2-3次除法和多次比较。

新算法 - modulus-equality技巧:

bool is_leap_year_fast(int year) {
    return (year & 3) == 0 && ((year % 25) != 0 || (year & 15) == 0);
}

核心思路:

性能测试结果:

在作者的机器上,新算法比传统算法快约30%。关键在于减少了除法运算,用更便宜的位运算和模25运算替代。

完整优化版本:

bool is_leap_year_optimized(int year) {
    // 快速路径:大多数年份能被4整除但不是100的倍数
    if ((year & 3) != 0) return false;

    unsigned int mod25 = year % 25;
    if (mod25 != 0) return true;

    // 慢速路径:检查400的倍数
    return (year & 15) == 0;
}

怎么感觉不是新东西

15 Different Ways to Filter Containers in Modern C++

这篇文章展示了C++中过滤容器的15种方法,从传统循环到C++23的新特性。

问题场景: 从vector中筛选出偶数

方法1 - 原始循环:

std::vector<int> filter_even_raw_loop(const std::vector<int>& input) {
    std::vector<int> result;
    for (size_t i = 0; i < input.size(); ++i) {
        if (input[i] % 2 == 0) {
            result.push_back(input[i]);
        }
    }
    return result;
}

方法2 - Range-based for:

std::vector<int> filter_even_range_for(const std::vector<int>& input) {
    std::vector<int> result;
    for (int val : input) {
        if (val % 2 == 0) {
            result.push_back(val);
        }
    }
    return result;
}

方法3 - std::copy_if + back_inserter:

std::vector<int> filter_even_copy_if(const std::vector<int>& input) {
    std::vector<int> result;
    std::copy_if(input.begin(), input.end(),
                 std::back_inserter(result),
                 [](int x) { return x % 2 == 0; });
    return result;
}

方法4 - 就地删除 erase-remove:

void filter_even_erase_remove(std::vector<int>& vec) {
    vec.erase(std::remove_if(vec.begin(), vec.end(),
                             [](int x) { return x % 2 != 0; }),
              vec.end());
}

方法5-7 - C++20 Ranges:

// Views - 惰性求值
auto filter_even_view(const std::vector<int>& input) {
    return input | std::views::filter([](int x) { return x % 2 == 0; });
}

// 转换回vector
std::vector<int> filter_even_ranges(const std::vector<int>& input) {
    auto filtered = input | std::views::filter([](int x) { return x % 2 == 0; });
    return std::vector<int>(filtered.begin(), filtered.end());
}

// C++23 ranges::to
std::vector<int> filter_even_ranges_to(const std::vector<int>& input) {
    return input
        | std::views::filter([](int x) { return x % 2 == 0; })
        | std::ranges::to<std::vector>();
}

方法8 - 并行算法:

std::vector<int> filter_even_parallel(const std::vector<int>& input) {
    std::vector<int> result;
    std::copy_if(std::execution::par,
                 input.begin(), input.end(),
                 std::back_inserter(result),
                 [](int x) { return x % 2 == 0; });
    return result;
}

方法9-15: 文章还介绍了自定义迭代器、表达式模板、C++23 generator等更高级的方法。

C++提供了从底层到高层的各种过滤容器的方法,ranges让代码更简洁,但底层方法在性能关键场景仍有用。

Unrolling loops

循环展开是编译器的常见优化,但需要编译时已知循环边界。

基本循环:

void process_array(int* arr, size_t n) {
    for (size_t i = 0; i < n; ++i) {
        arr[i] = arr[i] * 2 + 1;
    }
}

如果n是运行时变量,编译器很难展开。

编译时已知边界:

template<size_t N>
void process_array_fixed(int (&arr)[N]) {
    for (size_t i = 0; i < N; ++i) {
        arr[i] = arr[i] * 2 + 1;
    }
}

这种情况下,编译器能把循环完全展开:

// N=4时,编译器生成:
arr[0] = arr[0] * 2 + 1;
arr[1] = arr[1] * 2 + 1;
arr[2] = arr[2] * 2 + 1;
arr[3] = arr[3] * 2 + 1;

部分展开:

对于较大的循环,编译器会做部分展开:

// 原循环
for (size_t i = 0; i < 1000; ++i) {
    process(i);
}

// 编译器展开成4路并行
for (size_t i = 0; i < 1000; i += 4) {
    process(i);
    process(i+1);
    process(i+2);
    process(i+3);
}

好处:

代价:

现代编译器已经很智能了,手动展开通常没必要,但理解原理有助于写出编译器友好的代码。

A Guest Editorial

这篇编者按介绍了C++20协程特性,协程是”可以在中间暂停的函数”,之后可以恢复执行并保持状态。

三个关键字:

传统阻塞式服务器:

void session(Socket sock){
  char buffer[1024];
  int len = sock.read({buffer});
  sock.write({buffer,len});
  log(buffer);
}

阻塞在read和write上,无法处理其他连接。

协程版本:

Task<void> session(Socket sock){
  char buffer[1024];
  int len = co_await sock.async_read({buffer});
  co_await sock.async_write({buffer,len});
  log(buffer);
}

co_await标记暂停点,代码看起来是顺序的,但执行是异步的。不用回调地狱,不用手写状态机。

实际应用:

  1. 异步计算 - 简化服务器代码,顺序代码加暂停点替代回调嵌套
  2. 惰性求值 - 延迟计算,支持无限列表编程

架构组件: 四个核心要素协同工作:ReturnType(定义接口)、Promise(生产者侧数据存储)、coroutine_handle(帧指针)、Awaitable(挂起机制)。

协程初看复杂,但比回调简洁太多,暂停点清晰可控。

Why I Don’t Use AI

作者Andy Balaam列举了不使用大语言模型的四个主要理由:

1. 环境影响

数据中心能耗巨大,预计到2030年翻倍。爱尔兰的数据中心消耗了近五分之一的电力供应。AI公司理念就是无限制消耗资源,不可持续。

2. 工人剥削

数百万工人以极低工资(每小时1.32-2美元)标注数据训练模型,很多人处理恶心内容后心理创伤,”渗透进你大脑甩不掉”。

3. 输出不可靠且危险

AI系统会自信地输出错误信息,更可怕的是,聊天机器人据说鼓励过青少年自杀,引导用户进入”妄想螺旋”,心理健康风险严重。

4. 版权侵犯

AI训练违反授权协议,复制版权材料不署名也不遵守许可证,伤害开源创作者和依赖引用流量的商业网站。

另外,生产力提升缺乏实证支持,AI可能只是裁员借口。

作者态度很明确,AI公司的商业模式建立在剥削和侵权基础上,不值得支持。

Concurrency Flavours

Lucian Radu Teodorescu探讨了为什么需要并发,以及不同的动机如何塑造不同的并发方法。

并发定义:

并发在数学上是”工作项的严格偏序”,任务可以在时间上重叠而不是顺序执行。

四种主要动机:

1. 响应性(Responsiveness)

保持系统交互性,确保关键组件(如UI线程)始终可用。餐厅类比:主持人必须在2分钟内自由接待顾客。

2. 延迟优化(Latency Optimization)

通过做更多总工作来更快完成单个任务。并行前缀和算法展示了这种权衡——总操作更多但完成更快。

3. 吞吐量最大化(Throughput Maximization)

单位时间处理更多工作。多个厨师同时处理不同菜品增加客户容量,即使单个菜品时间增加。

4. 分解(Decomposition)

将系统结构化为独立可管理的组件,为了清晰而非性能。餐厅的专门角色或Web服务器的独立请求处理器。

关键区分:

核心信息:理解为什么需要并发比如何实现更重要。动机决定方案选择。

Coroutines – A Deep Dive

Quasar Chunawala深入探讨了C++协程的底层机制。

Promise类型:

协程的返回类型必须定义promise_type结构体,这类似异步编程中的future/promise,作为”调用者与协程交互的接口”。

示例1 - 基本Promise类型实现:

#include <coroutine>
#include <print>

struct Task{
  struct promise_type{
    Task get_return_object(){
      std::println("get_return_object()");
      return Task{ *this };
    }
    void return_void() noexcept {
      std::println("return_void()");
    }
    void unhandled_exception() noexcept {
      std::println("unhandled_exception()");
    }
    std::suspend_always initial_suspend() noexcept {
      std::println("initial_suspend()");
      return {};
    }
    std::suspend_always final_suspend() noexcept{
      std::println("final_suspend()");
      return {};
    }
  };
  explicit Task(promise_type&){
    std::println("Task(promise_type&)");
  }
  ~Task() noexcept{
    std::println("~Task()");
  }
};

Task coro_func(){
  co_return;
}

int main(){
  coro_func();
}

关键函数说明:

示例2 - 产出协程(使用co_yield):

#include <coroutine>
#include <print>
#include <iostream>
#include <string>

struct Task{
  struct promise_type{
    std::string output_data{};

    Task get_return_object(){
      return Task{ *this };
    }
    void return_void() noexcept {}
    void unhandled_exception() noexcept {}
    std::suspend_always initial_suspend() noexcept{ return {}; }
    std::suspend_always final_suspend() noexcept{ return {}; }

    std::suspend_always yield_value(std::string msg) noexcept{
      output_data = std::move(msg);
      return {};
    }
  };

  std::coroutine_handle<promise_type> handle{};

  explicit Task(promise_type& promise)
  : handle { std::coroutine_handle<promise_type>::from_promise(promise) }
  {}

  ~Task() noexcept{
    if(handle)
      handle.destroy();
  }

  std::string get(){
    if(!handle.done())
      handle.resume();
    return std::move(handle.promise().output_data);
  }
};

Task coro_func(){
  co_yield "Hello world from the coroutine";
  co_return;
}

int main(){
  auto task = coro_func();
  std::cout << task.get() << std::endl;
}

示例3 - 等待协程(使用co_await):

struct Task{
  struct promise_type{
    std::string input_data{};

    // ... 其他promise方法 ...

    auto await_transform(std::string) noexcept{
      struct Awaitable{
        promise_type& promise;

        bool await_ready() const noexcept{
          return true;
        }
        std::string await_resume() const noexcept{
          return std::move(promise.input_data);
        }
        void await_suspend(std::coroutine_handle<promise_type>) const noexcept{}
      };
      return Awaitable(*this);
    }
  };

  std::coroutine_handle<promise_type> handle{};

  void put(std::string msg){
    handle.promise().input_data = std::move(msg);
    if(!handle.done())
      handle.resume();
  }
};

Task coro_func(){
  std::cout << co_await std::string{};
  co_return;
}

int main(){
  auto task = coro_func();
  task.put("To boldly go where no man has gone before");
  return 0;
}

Awaitable对象三个关键方法:

示例4 - C++23 Generator:

#include <print>
#include <generator>

std::generator<int> makeFibonacciGenerator(){
  int i1{0};
  int i2{1};
  while(true){
    co_yield i1;
    i1 = std::exchange(i2, i1 + i2);
  }
  co_return;
}

int main(){
  auto fibo_gen = makeFibonacciGenerator();
  std::println("The first 10 numbers of the Fibonacci sequence are : ");
  int i{0};
  for(auto f = fibo_gen.begin(); f!=fibo_gen.end(); ++f){
    if(i == 10)
      break;
    std::println("F[{}] = {}", i, *f);
    ++i;
  }
  return 0;
}

C++23的std::generator简化了协程创建,不用手写promise_type。从手写promise到generator,协程从底层到高层都能玩。

Dynamic Memory Management on GPUs with SYCL

Russell Standish讨论了将动态内存管理库Ouroboros从CUDA移植到SYCL的经验。

核心问题:

传统GPU编程不支持kernel内动态内存分配。图算法等应用需要在加速器上堆分配,需要预分配内存块由专门分配算法管理。

SYCL vs CUDA:

SYCL使用标准C++编写host和kernel代码,CUDA区分两者。SYCL跨平台兼容(Intel、AMD、NVIDIA),但kernel上下文缺少一些标准库特性。

移植难点:

  1. 维度处理:CUDA假设3D线程布局;SYCL允许1-3D,需要使用get_global_linear_id()的代码
  2. 线程识别:CUDA的全局threadIdxblockIdx变量在SYCL需要通过函数栈传递nd_item参数
  3. 原子操作:CUDA原子库函数转换为SYCL原子引用类型
  4. 缺失特性nanosleep()和warp投票的active mask功能不可用

代码示例 - Active Mask获取(SYCL):

auto sg = i.get_sub_group();
auto activeMask = sycl::reduce_over_group(
  sg,
  1ULL << sg.get_local_linear_id(),
  sycl::bit_or<>()
);

这段代码在Intel GPU和CPU上正常运行,但在NVIDIA GPU上当不是所有subgroup线程都活跃时会死锁。

结果:

Ouroboros-SYCL性能与CUDA相当——基于页的算法在2倍以内,基于块的变体在统计误差范围内(使用Intel的oneAPI工具集)。

SYCL可行,但移植不是直接翻译,需要适应不同的编程模型。跨平台有代价,不同GPU厂商的行为差异需要注意。

Pop goes the…population count?

Matt Godbolt探讨了现代编译器如何优化population count操作——计算64位整数中设置的比特数。

问题和解法:

常见算法:循环时清除最低设置位,使用value &= (value - 1)。这个技巧有效是因为”减1总是将最底部的设置位向下移动一位,设置从那里开始的所有位”。

代码生成:

默认编译产生直接循环:

.L3:
  lea rax, [rdi-1]          ; rax = value - 1
  add edx, 1                ; ++result
  and rdi, rax              ; value &= value - 1
  jne .L3                   ; ...while (value)

使用-march=westmere,整个程序折叠成单条指令:popcnt rax, rdi

关键洞察:

编译器优化管道——特别是clang的”循环删除pass”——识别功能等价模式并替换为硬件指令。这展示了编译器的高超技艺:复杂算法变成单条CPU指令。

建议:

不要依赖编译器识别,直接使用标准库函数std::popcount确保正确代码生成并明确意图。

Population count在数据压缩、密码学和稀疏矩阵运算中有实际应用,别自己写循环,用标准库。

Division

整数除法在CPU上计算代价昂贵——x86处理器上最多100个周期,而加法只需一个周期。编译器优化除法运算以提升性能。

关键技术:右移替代2的幂次除法

最直接的优化是用右移运算替代2的幂次除法。例如,x / 512可以变成x >> 9

微妙之处:有符号 vs 无符号

文章揭示了关键区别:C要求有符号除法”向零舍入”,但移位实际上向负无穷舍入。这意味着:

编译器演示序列:

test edi, edi         ; 检查符号
lea eax, [rdi+511]    ; 应用偏移
cmovns eax, edi       ; 条件移动
sar eax, 9            ; 算术移位

优化策略:

除以常量时用无符号类型,向编译器表明可以用简单逻辑。这让写的代码和意图对齐。

完整示例:

// 低效 - 有符号除法
int divide_by_512_signed(int x) {
    return x / 512;
}

// 高效 - 无符号除法
unsigned int divide_by_512_unsigned(unsigned int x) {
    return x / 512;  // 编译器直接生成: shr eax, 9
}

理解这些编译器行为帮助开发者写出既符合语言规范又性能优秀的代码。除法贵,能优化就优化。

Flat Containers in C++23: Performance and Internals

C++23引入了std::flat_mapstd::flat_setstd::flat_multimapstd::flat_multiset——为读密集型工作负载优化的有序关联容器。

内部设计:

这些容器使用SoA(Structure of Arrays)方法,将键和值存储在独立vector而不是配对节点。这种布局在迭代和二分查找时增强缓存局部性,但完整操作需要”两个vector”。

性能特征:

优势:

劣势:

实际用例:

作者推荐flat容器用于”大多是读或频繁迭代键和值”的场景,如配置缓存、元数据存储或路由表。

代码示例:

#include <flat_map>
#include <string>

std::flat_map<int, std::string> config = {
    {1, "option1"},
    {2, "option2"},
    {3, "option3"}
};

// 查找很快
if (auto it = config.find(2); it != config.end()) {
    std::cout << it->second << '\n';
}

// 遍历也很快
for (const auto& [key, value] : config) {
    std::cout << key << ": " << value << '\n';
}

结论:

虽然专门化,flat容器对一般采用来说”太小众”。标准std::mapstd::unordered_map在大多数场景提供优秀的插入性能和可接受的内存权衡。

适合读多写少的场景,其他情况还是老老实实用map和unordered_map。

Making LLVM Compilation Data Accessible: A Unified Visualization Tool for Compiler Artifacts

LLVM Advisor是在Google Summer of Code 2025期间开发的可视化工具,集中展示通常分散在各种格式和输出中的编译数据。

功能:

工具由三个主要部分组成:

  1. 编译器包装器:自动收集编译产物,无需修改构建系统
  2. 存储层:以JSON格式组织数据,具有项目级链接能力
  3. Web仪表板:提供交互式可视化,包括源代码查看、性能时间线和优化分析

如何帮助开发者:

不用手动搜索数千条优化备注或跨编译单元关联数据,开发者获得:

这个工具特别有利于新贡献者、优化性能的经验丰富开发者、学习编译器的学生,以及编译器专业知识资源有限的欠发达地区的开发者。

在线演示:https://llvm-advisor.onrender.com/

编译器内部不再是黑盒,可视化工具让优化决策变得透明,值得尝试。

Time in C++: std::chrono::high_resolution_clock — Myths and Realities

这篇文章揭穿了关于std::chrono::high_resolution_clock的几个常见误解。

误解1:更高分辨率 = 更好的计时

文章澄清:”时钟可能表示纳秒,但由于硬件或OS调度仍然不准确。”分辨率和准确性是不同的——细粒度刻度不保证精度。

误解2:它独一无二优越

实际上,high_resolution_clock“可能是system_clocksteady_clock的同义词”在大多数平台上,相比直接使用这些没有真正优势。

误解3:它总是稳定的

最关键的揭穿:如果别名到system_clock,时钟可以在系统时间调整期间向后跳,使其对间隔测量不可靠。

代码示例:检查时钟身份

using std::chrono::high_resolution_clock;
std::cout << "is steady? " << high_resolution_clock::is_steady << "\n"
          << "equals system_clock? "
          << std::is_same_v<high_resolution_clock,
                           std::chrono::system_clock> << "\n";

测试结果示例:

// 在某些平台上
std::cout << "Period: "
          << high_resolution_clock::period::num << "/"
          << high_resolution_clock::period::den << "s\n";
// 可能输出:Period: 1/1000000000s(纳秒)

// 但精度可能只有微秒级
auto t1 = high_resolution_clock::now();
// 做些工作
auto t2 = high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1);

建议:

间隔测量用steady_clock,时间戳用system_clock。只有在通过平台测试验证high_resolution_clock既稳定又真正不同于steady_clock后才使用它。

别被名字骗了,high_resolution_clock不一定高分辨率也不一定稳定,大多数情况直接用steady_clock。

为什么不能分发 C++ 二进制模块接口

YexuanXiao 投稿

这篇文章从AST(抽象语法树)不一致的角度解释了为什么C++的BMI(二进制模块接口)文件没法跨编译器分发。

作者列了六个导致AST不一致的原因:

  1. 编译器特定的宏:不同编译器定义不同的预处理符号,比如__clang__或者_MSC_VER
  2. 用户可配置的宏:像NDEBUG_UNICODE这些宏会影响库的功能
  3. C++标准版本差异:不同的语言标准会导致标准库和第三方库暴露不同的特性
  4. 实现定义的行为:编译器参数控制的行为,比如字符编码(-fexecution-charset)和类型符号性(-funsigned-char)
  5. 编译器实现细节:不同的内建函数支持和内部类型表示
  6. 扩展差异:编译器支持的扩展不同,比如__int128_t在GCC/Clang有但MSVC没有

关键点在于,所有导致AST不同的行为都会违反ODR规则。这不是模块特有的问题,头文件也有同样的问题。但模块会把编译器特定的构造保存在AST里,导致BMI文件没法跨工具链兼容。

笔者:说白了就是想法很美好,现实很骨感。模块本来想解决头文件的问题,结果发现跨编译器分发BMI这事儿根本不靠谱,因为每个编译器都有自己的小九九。还是老老实实分发源码吧。

可预测的内存访问快得多

Daniel Lemire测试了64 MiB数组上五种不同的访问模式,用来说明现代处理器的硬件预取有多智能。

五种访问模式:

  1. 顺序访问 - 按顺序每隔8个整数读一个
  2. 随机访问 - 随机顺序读每隔8个整数
  3. 反向访问 - 从末尾开始顺序读
  4. 交错访问 - 在数组的前半部分和后半部分之间交替
  5. 跳跃访问 - 从开始和末尾位置交替读取

测试结果:除了纯随机访问,其他所有模式都快得多。随机访问性能差是因为硬件预取器没法预测不可预测的内存位置。

笔者:这个结果其实不意外,顺序访问对cache友好是常识。但有意思的是,即使是反向访问、交错访问这种看起来不太规则的模式,CPU的预取器也能识别出规律来优化。现代处理器是真的聪明,别瞎写代码糟蹋它。

使用concepts时要配合std::remove_cvref_t

当你用转发引用和concept的时候,引用限定的类型会导致问题:

template<Quantity Q> void foo(Q&& q);

当你传一个MyQuantity左值时,模板参数Q会推导成MyQuantity&而不是MyQuantity。还可能是const MyQuantity&MyQuantity&&或者volatile MyQuantity

concept必须接受所有这些形式,但如果你的concept假设是纯值类型,编译就会失败。

问题示例:

template<typename T>
concept Quantity = requires(T t) {
    typename T::unit;
};

struct Gram { using unit = int; };

template<Quantity Q> void foo(Q&& q) { }

int main() {
    Gram g;
    foo(g); // 错误:Q推导成Gram&,失败
}

问题在于:通过引用限定的类型访问T::unit会导致concept检查失败。

解决方案:

在应用concept之前先剥掉引用和const/volatile限定符:

template<typename Q>
requires Quantity<std::remove_cvref_t<Q>>
void foo(Q&& q);

std::remove_cvref_t会在concept检查前移除Q的所有引用和cv限定符,确保concept验证的是实际的值类型而不是限定后的推导类型。

权衡:

这种方法牺牲了可读性,但能保证正确性。文章提到”C++26会带来解决方案”来解决这个冗长问题。

笔者:这是个经典的坑。用转发引用的时候很容易忘记Q其实是个引用类型。std::remove_cvref_t是个好东西,用concept的时候基本上都得配合它用。不过写起来确实啰嗦,期待C++26能简化一下。

未定义引用错误:模板特化缺失

当你定义一个模板函数但只有特化没有基础实现时,未特化的模板类型会产生链接时错误:

template<typename T> T get();

template<>
int get<int>() {
    return 1;
}

尝试调用get<short>()会导致”undefined reference”错误(gcc/clang)或者”unresolved external symbol”(MSVC)。这些错误在构建过程后期出现,没有行号,在大型代码库里调试很困难。

解决方案:

“直接delete掉基础情况“,文章这么说的。把未定义的基础模板替换成:

template<typename T> T get() = delete;

template<>
int get<int>() {
    return 1;
}

现在尝试get<short>()会产生编译时错误,带着具体的行号,立即定位到问题发生的地方。

为什么有用:

使用= delete(C++11通过缺陷报告DR 941引入的特性)把链接时失败转换成编译时失败,让开发者能直接在源码处捕获特化缺口,而不是在链接时才发现。

笔者:这个技巧简单但实用。链接错误真的烦,尤其是大项目里,找半天都不知道哪里调用了未特化的模板。直接delete掉,编译器马上告诉你哪里有问题,省事儿。

Raymond Chen tracking pointer系列

Raymond Chen写了16篇系列文章,讲如何实现一个tracking pointer(跟踪指针),这种智能指针能跟随对象在内存中的移动。

核心概念

tracking pointer的关键思想是:跟踪对象的内容而不是内存地址。当你移动构造一个对象时,现有的tracking pointer会自动更新到新位置。

设计规则:

关键限制:这本质上是个单线程概念。没有机制防止对象重定位期间的并发访问。

实现方案1:std::list

最简单的想法是用std::list存储指针,但有个问题:

trackable_object& operator=(trackable_object&& other) {
    m_trackers.clear();  // 让现有的tracking pointer都失效
    m_trackers = std::move(other.m_trackers);  // 可能抛异常!
    update_all_pointers();
    return *this;
}

这个实现的问题是移动赋值可能抛异常,破坏了强异常保证。

实现方案2:std::vector

换成std::vector也有类似问题,而且vector在增长时需要重新分配内存,性能不好。

实现方案3:循环双向链表(最佳方案)

核心思想:tracking pointer本身就是链表的节点

struct tracking_node {
    tracking_node* next;
    tracking_node* prev;
};

三种构造模式:

关键方法:

template<typename T>
struct tracking_ptr : tracking_ptr_base<std::remove_cv_t<T>>
{
private:
    using base = tracking_ptr_base<std::remove_cv_t<T>>;
    using MP = tracking_ptr<std::remove_cv_t<T>>;

public:
    T* get() const { return this->tracked; }

    using base::base;
    tracking_ptr(MP const& other) : base(other) {}
    tracking_ptr(MP&& other) : base(std::move(other)) {}
};

关键优势:所有tracking pointer操作都是noexcept,因为它们从不分配内存。

实现方案4:shared_ptr间接

另一种思路是用shared_ptr<T*>实现O(1)的对象移动复杂度:

template<typename T>
struct tracking_ptr_base
{
    tracking_ptr_base() noexcept = default;
private:
    friend struct trackable_object<T>;
    tracking_ptr_base(std::shared_ptr<T*> const& ptr) noexcept :
        m_ptr(ptr) { }
protected:
    std::shared_ptr<T*> m_ptr;
};

这个方案用了间接层:每个trackable object维护一个指向指针的shared pointer。所有tracking pointer引用同一个shared pointer,实现单点更新语义。

权衡:构造函数因为需要分配内存变成了可能抛异常的。

为了恢复强异常保证,移动赋值需要小心处理:

trackable_object& operator=(trackable_object&& other) {
    *std::exchange(m_tracker, other.transfer_out()) = nullptr;
    set_target(owner());
    return *this;
}

关键原则:在最后一个可能抛异常的点之前,不能做任何不可逆的改变。

实现方案5:自定义引用计数

为了避免shared_ptr的开销,可以自己实现一个简单的引用计数:

struct data
{
    data(T* tracked) noexcept : m_tracked(tracked) {}
    unsigned int m_refs = 1;
    T* m_tracked;
};

struct deleter
{
    void operator()(data* p)
    {
        if (--p->m_refs == 0)
        {
            delete p;
        }
    }
};

std::unique_ptr配合自定义deleter来管理清理,减少模板代码量。这个实现是非线程安全的,针对单线程使用优化。

笔者:循环链表方案最优雅,noexcept是关键。实际用的少,但能学到移动语义和异常安全。

为什么我们需要SIMD指令?

Daniel Lemire用字符搜索的例子说明了SIMD的必要性。

朴素实现:

const char* naive_find(const char* start, const char* end, char character) {
    while (start != end) {
        if (*start == character) {
            return start;
        }
        ++start;
    }
    return end;
}

这个函数逐字符迭代,每次检查当前字符是否匹配目标字符。

性能对比(Apple M4处理器):

实现方式 性能
朴素搜索 4 GB/s
simdutf::find (SIMD) 110 GB/s

SIMD版本快了20倍以上,因为它大幅减少了所需的指令数量。

性能细节:

作者指出,如果没有SIMD优化,字符串搜索吞吐量(~4 GB/s)会低于典型的磁盘带宽(5-15 GB/s),这让SIMD在实际应用中几乎是必需的。

笔者:这个例子很直观。4 GB/s vs 110 GB/s,差距大到离谱。不用SIMD的话,CPU处理速度都跟不上硬盘读取速度,那还玩个屁。现在的字符串库、JSON解析库基本都用SIMD优化了,不然根本没法跟别人竞争。不过SIMD代码写起来确实麻烦,可读性差,维护成本高。好在有simdutf这种现成的库可以用。

auto返回类型的隐藏编译时成本

Andreas Fertig测量了auto返回类型对编译时间的影响。

代码示例:

不用auto:

int Fun(bool b, int val) {
  if(b) {
    return val * 2;
  } else {
    return val / 2 * 3;
  }
}

用auto:

auto Fun(bool b, int val) {
  if(b) {
    return val * 2;
  } else {
    return val / 2 * 3;
  }
}

编译时间测量结果(Clang 19,单个翻译单元):

关键发现:

当在头文件中使用auto返回类型时,会出现一个额外的编译步骤叫”ParseFunctionDefinition”。这是因为编译器在解析声明时必须查找函数定义。不用auto的话,编译器会推迟解析函数体,直到实际调用它。

建议: 为了优化构建时间,在头文件函数中避免使用auto返回类型。

25%的编译时间增长,我操

处理器越来越宽

现代处理器通过超标量执行提升性能——每个时钟周期同时执行多条指令。因为频率限制在~5 GHz(散热问题),厂商就加执行单元。

处理器能力:

向量寄存器操作:Zen 5有”四个512位寄存器执行单元,其中两个能做乘法”,通过寄存器打包同时处理多个值。

关键点:更宽的处理器设计需要复杂的架构支持:”你得把数据送到这些单元,得安排计算顺序,处理分支”。不是简单加执行单元就完事。

加宽比加核心,能榨干单线程性能。不过写代码也得配合,乱写一通CPU再宽也没用。

C++ nth_element算法

nth_element()做部分排序,把第n个位置的元素改成排序后应该在那个位置的元素。

特点:

基础示例:

#include <iostream>
#include <vector>
#include <algorithm>

int main()
{
    std::vector<int> v {9, 4, 3, 8, 1, 2, 1, 8, 7, 6};
    auto mid = v.begin() + v.size() / 2;
    std::nth_element(v.begin(), mid, v.end());

    for(auto const & e : v)
        std::cout << e << " ";
}
// 输出: 2 4 3 1 1 6 8 8 7 9

计算中位数:

避免全排序O(n log n),平均O(n):

double compute_median(std::vector<int> data)
{
    size_t mid = data.size() / 2;
    if (data.size() % 2 == 1) {
        std::nth_element(data.begin(), data.begin() + mid, data.end());
        return data[mid];
    } else {
        std::nth_element(data.begin(), data.begin() + mid - 1, data.end());
        int val1 = data[mid - 1];
        std::nth_element(data.begin(), data.begin() + mid, data.end());
        int val2 = data[mid];
        return (val1 + val2) / 2.0;
    }
}

找最小N个元素:

std::vector<int> v {9, 4, 3, 8, 1, 2, 1, 8, 7, 6};
int n = 3;

std::nth_element(v.begin(), v.begin() + n, v.end());
std::sort(v.begin(), v.begin() + n);
// 前3个元素现在是排序后的最小值

找最大N个元素:

std::nth_element(v.begin(), v.begin() + n, v.end(), std::greater<>{});
std::sort(v.begin(), v.begin() + n);

应用场景:统计计算、图像处理(中值滤波)、排行榜Top N、性能监控、快排优化。

不需要全排序就别用sort,nth_element快得多。算中位数、百分位数这种场景特别有用。

C++26: Concept和变量模板作为模板参数

提案P2841R7被C++26接受,允许concept和变量模板作为模板参数,解决当前模板元编程的可读性和性能问题。

之前的冗长约束:

template<typename Q>
requires Quantity<std::remove_cvref_t<Q>>
void foo(Q&& q);

之后的Concept模板模板参数:

template <typename T, template <typename> concept C>
concept decays_to = C<std::decay_t<T>>;

template <decays_to<Quantity> Q>
void foo(Q&& q);

变量模板模板参数:

之前:

template <template <typename> typename p, typename... Ts>
constexpr std::size_t count_if_v = (... + p<Ts>::value);

之后:

template <template <typename> auto p, typename... Ts>
constexpr std::size_t count_if_v = (... + p<Ts>);

去掉::value,消除不必要的类模板实例化开销。

统一模板参数语法:

template<
    template <typename T> typename TT,    // 类模板
    template <typename T> concept C,      // Concept
    template <typename T> auto VT         // 变量模板
>

好处:更清晰的语法、减少样板代码、提升编译时性能、更有表达力。

模板元编程能少写不少boilerplate

C++26的template for和:expand:

Template For循环(提议中):

反射用的template for语法:

template <typename E>
constexpr std::string enum2Strg(E value) {
    template for (constexpr auto e : std::meta::enumerators_of(^^E)) {
        if (value == [:e:])
            return std::string(std::meta::identifier_of(e));
    }
    return "???";
}

:expand:临时方案(当前替代):

因为template for还没标准化,现在用这个等价模式:

[:expand(std::meta::enumerators_of(^^E)):] >> [&]<auto e>{
    if (value == [:e:]) {
        result = std::meta::identifier_of(e);
    }
};

实际例子:命令行解析器:

template <typename Opts>
consteval auto parse_options(std::span<const std::string_view> args) -> Opts
{
    Opts opts;
    template for (constexpr auto dm : nonstatic_data_members_of(^^Opts)) {
        // 查找匹配参数
        auto it = std::ranges::find_if(args,
            [](std::string_view arg) {
                return arg.starts_with("--") &&
                arg.substr(2) == identifier_of(dm);
            });

        // 解析并赋值
        using T = typename[:type_of(dm):];
        auto iss = std::ispanstream(it[1]);
        if (iss >> opts.[:dm:]; !iss) {
            // 错误处理
        }
    }
    return opts;
}

关键点::expand:只是临时workaround,等template for标准化。

不过现在还是提案阶段,:expand:这语法看着有点怪。

Raymond Chen C++/WinRT系列:非Windows Runtime类型的IAsyncOperation

Part 1 Part 2

问题:IAsyncOperation<T>中的T必须是Windows Runtime类型,因为接口ID生成算法只对WinRT类型有效。

解决方案1:用其他协程库

cppcoro::task<T>wil::task<T>concurrency::task<T>,它们支持任意C++类型。

解决方案2:输出参数模式

通过shared_ptr传结果而不是返回值:

保证结果对象活到协程完成,防止内存损坏。

解决方案3:IInspectable包装器

创建实现IInspectable的包装结构,隐藏非WinRT类型:

struct IValueAsInspectable : ::IUnknown
{
    std::type_info const* type;

    template<typename T>
    T& get_value()
    {
        if (*type != typeid(T)) {
            throw std::bad_cast();
        }
        return static_cast<ValueAsInspectable<T>*>(this)->value;
    }
};

template<typename T>
struct ValueAsInspectable :
    winrt::implements<ValueAsInspectable<T>,
    winrt::Windows::Foundation::IInspectable,
    IValueAsInspectable>
{
    T value;

    template<typename...Args>
    ValueAsInspectable(Args&&... args) :
        value{ std::forward<Args>(args)... }
    {
        this->type = std::addressof(typeid(T));
    }
};

返回包装后的值,接收端用get_self()提取。通过std::type_info比较做运行时类型检查,类型错误抛std::bad_cast

Non-owning Range

非所有权range不管理元素内存。区分:

String View(Non-Owning Borrowed Range):

std::string_view s  = "foobar";      // 绑定到静态存储 [O(1)]
std::string_view s2 = s;             // 绑定到同一存储 [O(1)]

char c = *std::ranges::min_element(s);
assert(c == 'a');

char d = *std::ranges::min_element(std::string_view{"foobar"});
assert(d == 'a');

关键点:拷贝string_view是O(1),因为只存指针,不存底层数据。

C++20 Range适配器:

标准库限制:

std::vector vec{-1, 2, -3, 1, 7};
auto non_neg = [] (int i) { return i >= 0; };

auto it1 = std::ranges::min_element(vec | std::views::filter(non_neg));
// 问题:filter_view永远不是borrowed

RADR库解决方案:

auto it2 = std::ranges::min_element(std::ref(vec) | radr::filter(non_neg));
assert(*it == 1);  // 有效:所有radr适配器在左值上都是borrowed

标准库的range适配器在borrowed这块设计有缺陷,RADR算是个补丁。

开源项目介绍


上一期

本期

下一期

看到这里或许你有建议或者疑问或者指出错误,请留言评论! 多谢! 你的评论非常重要!也可以帮忙点赞收藏转发!多谢支持! 觉得写的不错那就给点吧, 在线乞讨 微信转账