C++ 中文周刊 2026-01-09 第193期

周刊项目地址

公众号

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

qq群 753792291 答疑在这里

RSS

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

懒狗忘了


资讯

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

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

性能周刊

文章

No Graphics API

现代GPU已经够成熟了,不需要那些复杂的图形API了。DirectX 12/Vulkan/Metal这些API是13年前为异构硬件设计的,现在GPU都统一了,还搞那么复杂干嘛?

最大的问题是PSO(管线状态对象)排列爆炸,搞出100GB+的缓存

他的想法是简化成类似CUDA的风格:

// 直接用指针分配内存
uint32* numbers = gpuMalloc(1024 * sizeof(uint32));
for (int i = 0; i < 1024; i++) numbers[i] = random();
gpuFree(numbers);

// 根参数直接传指针
struct alignas(16) Data {
    float16x4 color;
    const uint8* lut;
    const uint32* input;
    uint32* output;
};

void main(uint32x3 threadId : SV_ThreadID, const Data* data) {
    uint32 value = data->input[threadId.x];
    data->output[threadId.x] = value;
}

// 图形管线设置简化成这样
GpuRasterDesc rasterDesc = {
    .depthFormat = FORMAT_D32_FLOAT,
    .colorTargets = ,
};
GpuPipeline pipeline = gpuCreateGraphicsPipeline(vertexIR, pixelIR, rasterDesc);

整个原型API只要150行代码,Vulkan可是20,000+行啊。能简化是好事,不过这得看硬件厂商买不买账了

Deducing the consequences of Windows clipboard text formats on UTF-8

Windows剪贴板对UTF-8的支持就是个坑。问题在于:

结论很简单:UTF-8程序就直接用CF_UNICODETEXT,别折腾其他格式了

不懂windows,就不多嘴了

Modern C++ Firmware 系列

这是个嵌入式固件开发系列,讲怎么在小型关键系统上用现代C++。Part 2讲为什么选C++20,Part 3讲具体的硬性规则

为什么C++20?

利用c++20 的新api来约束

嵌入式的约束很明确:

C++20有这些:

硬性规则

1. 禁用异常和动态分配

热路径里绝对不能分配内存,要用固定容量容器:

class EventQueue final {
public:
    static constexpr std::size_t kMaxEventsPerTick = 16U;

    [[nodiscard]] bool try_push(std::uint16_t event) noexcept {
        if(this->count_ >= kMaxEventsPerTick) {
            ++this->overflow_count_;  // 溢出了就计数,别崩
            return false;
        }
        this->events_[this->count_] = event;
        ++this->count_;
        return true;
    }

    void clear() noexcept { this->count_ = 0U; }
    [[nodiscard]] std::size_t size() const noexcept { return this->count_; }

private:
    std::array<std::uint16_t, kMaxEventsPerTick> events_{};
    std::size_t count_{0U};
    std::uint32_t overflow_count_{0U};  // 记录溢出次数,调试用
};

2. 热路径禁用虚函数

用Concepts做编译期多态:

template <typename T>
concept HardwarePlatform = requires(T p) {
    { p.initialize() } noexcept -> std::same_as<bool>;
    { p.read_inputs() } noexcept;
    { p.write_outputs() } noexcept;
};

template <HardwarePlatform P>
void run_tick(P& platform) noexcept {
    platform.read_inputs();
    platform.write_outputs();
}

编译期就能确定调用哪个函数,不用虚函数表那一套

3. 用std::span传缓冲区

[[nodiscard]] bool build_status_line(std::span<char> out) noexcept {
    if(out.size() < 8U) { return false; }
    out[0] = 'O';
    out[1] = 'K';
    return true;
}

Understanding and mitigating a stack overflow in our task sequencer

Raymond Chen发现他们的task_sequencer会栈溢出。问题在于一堆任务同步完成的时候,协程恢复会递归调用,栈越堆越深

解决办法是强制切换线程,打断递归:

struct task_sequencer
{
    task_sequencer(winrt::DispatcherQueue const& queue = nullptr)
    : m_queue(queue) {}

    template<typename Maker>
    auto QueueTaskAsync(Maker&& maker) ->decltype(maker())
    {
        auto task = [&]() -> Async
        {
            completer completer{ current };
            auto local_maker = std::forward<Maker>(maker);
            auto local_queue = m_queue;

            co_await suspend;
            if (m_queue == nullptr) {
                co_await winrt::resume_background();  // 切到后台线程
            } else {
                co_await winrt::resume_foreground(local_queue);  // 或者切到指定队列
            }
            co_return co_await local_maker();
        }();
        // ...
    }
};

线程切换会强制展开栈,就不会溢出了。

Parsing IP addresses quickly (portably, without SIMD magic)

Daniel Lemire测试了几种解析IPv4地址的方法,不用SIMD。结论是手动展开循环最快:

性能对比(Apple M4):

std::from_chars慢了4倍多,我操?

手动展开的代码长这样:

std::expected<uint32_t, parse_error> parse_manual_unrolled(const char *p, const char *pend) {
    uint32_t ip = 0;
    int octets = 0;
    while (p < pend && octets < 4) {
        uint32_t val = 0;
        if (p < pend && *p >= '0' && *p <= '9') {
            val = (*p++ - '0');
            if (p < pend && *p >= '0' && *p <= '9') {
                if (val == 0) {  // 01.02这种不合法
                  return std::unexpected(invalid_format);
                }
                val = val * 10 + (*p++ - '0');
                if (p < pend && *p >= '0' && *p <= '9') {
                    val = val * 10 + (*p++ - '0');
                    if (val > 255) {  // 超过255不行
                      return std::unexpected(invalid_format);
                    }
                }
            }
        } else {
            return std::unexpected(parse_error::invalid_format);
        }
        ip = (ip << 8) | val;
        octets++;
        if (octets < 4) {
            if (p == pend || *p != '.') {
              return std::unexpected(invalid_format);
            }
            p++;
        }
    }
    if (octets == 4 && p == pend) {
        return ip;
    } else {
        return std::unexpected(invalid_format);
    }
}

就是把循环展开,少点分支判断

By how much does your memory allocator overallocate?

new char[4096]实际分配多少内存?答案是会多分配一点

Linux:请求4096字节,实际给4104字节,多8字节可以用。开销0.4%,还行

macOS:这个就夸张了。请求3585字节,给你4096字节,浪费14%!macOS喜欢按512字节对齐

可以用 malloc_usable_size查实际能用多少。这种过度分配对小对象影响大,大内存反而无所谓

Software taketh away faster than hardware giveth

Herb Sutter讲为什么C++和Rust还在快速增长

核心观点:软件消耗算力的速度比硬件进步快,性能需求永远满足不了

数据很有意思:

关于安全性,他的观点很实在:

C++26要加的新东西:

性能需求一直在涨,C++不会过时

Unsigned char std::basic_string<> in C++

std::basic_string<uint8_t>处理二进制数据?别这么干了

问题在于 std::basic_string依赖 std::char_traits<T>,标准只保证 char/wchar_t这些类型有。uint8_t以前是”意外”能用,LLVM 19.1.0直接把基础模板删了

解决办法

vector够了

Implementing vector<T>

手写vector,教学向的文章。关键是要分离内存分配和对象构造,还要处理异常安全

template <typename T>
class vector {
private:
    T* m_data;
    std::size_t m_size;       // 实际元素数量
    std::size_t m_capacity;   // 容量
};

reserve要这么写才异常安全:

void reserve(size_type new_capacity){
    if(new_capacity <= capacity()) return;
    auto ptr = allocate_helper(new_capacity);  // 先分配新内存
    try {
        copy_old_storage_to_new(m_data, m_size, ptr);  // 拷贝可能抛异常
    } catch(std::exception& ex){
        deallocate_helper(ptr);  // 失败了释放新内存
        throw;  // 继续抛,对象状态不变
    }
    std::destroy(m_data, m_data + m_size);  // 成功了才销毁旧数据
    deallocate_helper(m_data);
    m_data = ptr;
    m_capacity = new_capacity;
}

先做可能失败的操作,成功了再修改状态。这是异常安全的基本套路

文章写得挺详细,不过说实话,谁手写啊。面试可能会考?

Time in C++: Additional clocks in C++20

C++20加了5种新时钟,因为”世界运行在多个时间尺度上”

  1. utc_clock:有闰秒的UTC时间
  2. tai_clock:国际原子时间,1958年开始,不含闰秒
  3. gps_clock:GPS时间,1980年开始
  4. file_clock:文件系统时钟,跟 std::filesystem配套
  5. local_t:本地时间,但不指定时区

local_t要跟时区配合用:

auto local = std::chrono::local_time<std::chrono::minutes>{ ... };
auto tz = std::chrono::locate_zone("Europe/Berlin");
auto sys_time = tz->to_sys(local);  // 转成系统时间

Raymond Chen的内存块交换系列

Raymond Chen写了一系列文章,讲怎么只用前向迭代器交换内存块。为什么要研究这个?因为 std::rotate只需要前向迭代器,但常见的实现方法需要双向迭代器

相邻块交换

设三个指针 firstmidlast,要把块A和块B交换位置。思路是逐个交换元素,直到较小的块移动完,然后递归处理剩余部分

template<typename ForwardIt>
void rotate_adjacent(ForwardIt first, ForwardIt mid, ForwardIt last) {
    if (first == mid || mid == last) return;

    auto p = first;
    auto q = mid;

    while (true) {
        std::iter_swap(p++, q++);  // 交换元素

        if (p == mid) {
            // 块A用完了,块B还有剩余
            if (q == last) return;  // 都用完了
            mid = q;  // 递归处理剩余的
        } else if (q == last) {
            // 块B用完了,块A还有剩余
            // 递归处理剩余的
            return rotate_adjacent(p, mid, last);
        }
    }
}

复杂度O(n)次交换,O(1)空间,挺优雅的

非相邻块交换

更复杂的场景:内存排列是 A1, A2, B1, B2, C1, C2, D1, D2, D3, E1,要交换B和D

template<typename ForwardIt>
void swap_nonadjacent(ForwardIt b_start, ForwardIt b_end,
                      ForwardIt d_start, ForwardIt d_end) {
    auto p = b_start;
    auto q = d_start;

    // 同时遍历B和D,交换元素
    while (p != b_end && q != d_end) {
        std::iter_swap(p++, q++);
    }

    // 哪个块先用完,就旋转剩余部分
    if (p == b_end && q != d_end) {
        // B用完了,旋转剩余的D和C
        rotate_adjacent(b_start, q, d_end);
    } else if (q == d_end && p != b_end) {
        // D用完了,旋转剩余的B和C
        rotate_adjacent(p, b_end, d_start);
    }
}

总交换次数还是n次(最优)

优化改进

评论区有人(Neil Rashbrook)提了个优化:原来的方法要三次旋转(2n次交换),可以优化成两次旋转:

  1. 旋转BCD把D移到前面
  2. 旋转BC把C移到前面
// 第一步:旋转BCD,把D移到前面
// A1 A2 | B1 B2 C1 C2 D1 D2 D3 | E1
// 变成:A1 A2 | D1 D2 D3 B1 B2 C1 C2 | E1
rotate_adjacent(b_start, d_start, d_end);

// 第二步:旋转BC,把C移到前面
// A1 A2 D1 D2 D3 | B1 B2 C1 C2 | E1
// 变成:A1 A2 D1 D2 D3 C1 C2 B1 B2 E1
rotate_adjacent(after_d, b_new_pos, c_end);
交换次数变成2n − max( B , D ),省了点

这系列文章挺有意思的

Raymond Chen的Windows插入符定位系列

讲怎么找Windows的插入符(caret,就是那个闪烁的光标)位置

基础方法

GetCaretPos只能获取当前线程的插入符,要获取全局的得用 GetGUIThreadInfo

GUITHREADINFO info = { sizeof(info) };
if (GetGUIThreadInfo(0, &info)) {
    if (info.flags & GUI_CARETBLINKING) {
        MapWindowPoints(info.hwndCaret, nullptr, (POINT*)&info.rcCaret, 2);
        SetCursorPos(info.rcCaret.right - 1, info.rcCaret.bottom - 1);
    }
}

Active Accessibility方法

问题是很多现代应用(VS、Chrome、Word)用的是自定义插入符,GUI_CARETBLINKING标志不会设置。这时候要用Active Accessibility接口:

GUITHREADINFO info = { sizeof(GUITHREADINFO) };
if (GetGUIThreadInfo(0, &info)) {
    if (info.hwndFocus != nullptr) {
        Microsoft::WRL::ComPtr<IAccessible> acc;
        if (SUCCEEDED(AccessibleObjectFromWindow(info.hwndFocus, OBJID_CARET,
            IID_PPV_ARGS(&acc))) && acc) {
            long x, y, cx, cy;
            VARIANT vt{};
            vt.vt = VT_I4;
            vt.lVal = CHILDID_SELF;
            if (acc->accLocation(&x, &y, &cx, &cy, vt) == S_OK) {
                SetCursorPos(x + cx - 1, y + cy - 1);
                return;
            }
        }
    }
}

这套能在大多数应用里工作,但Windows Terminal和计算器不支持


上一期

本期

下一期

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