Skip to content

Instantly share code, notes, and snippets.

@KuRRe8
Last active May 16, 2025 15:15
Show Gist options
  • Save KuRRe8/bdf62eb11a81ddc37010072fe6bacee8 to your computer and use it in GitHub Desktop.
Save KuRRe8/bdf62eb11a81ddc37010072fe6bacee8 to your computer and use it in GitHub Desktop.
现代C++的一些新特性,以17、20、23版本为例

现代C++

C++ 11/14作为一个奠基版本,构造了近年来编写C++的新范式。

本人熟悉的主要语言技术栈有C/C++, Python, Matlab, C#, 相比之下,C++的变化是最频繁的,也是最有趣的

多数人已然熟悉C++11/14的用法,本Gist仓库旨在总结一些17及以后版本的特性。

欢迎在讨论区发表相应见解。

目录

C++17 语言与标准库特性概览


一、语言核心特性

1. 结构化绑定(Structured Bindings)

引入语法糖,解构类型为 tuple、pair 或具有 get<> 成员的结构。

std::pair<int, double> p{42, 3.14};
auto [a, b] = p;  // a: int, b: double

结构绑定等价于调用 std::get<0>(p)std::get<1>(p)

2. if / switch 初始化语句

允许在条件判断前进行局部变量定义:

if (int x = f(); x > 0) {
    // 使用 x
}

用于限制作用域并提升代码清晰性。

3. 折叠表达式(Fold Expressions)

针对可变参数模板提供简洁聚合运算方式。

template<typename... Args>
auto sum(Args... args) {
    return (... + args); // 从左到右相加
}

形式支持:

  • (... op pack)
  • (pack op ...)
  • (pack op ... op init)

4. constexpr 语义增强

在 constexpr 函数体中允许使用:

  • 条件语句(if、switch)
  • 循环(for、while)
  • 局部变量定义
constexpr int factorial(int n) {
    int result = 1;
    for (int i = 1; i <= n; ++i) result *= i;
    return result;
}

5. 内联变量(inline variables)

用于头文件中全局 constexpr 定义,避免多重定义。

inline constexpr int version = 1;

6. [[nodiscard]] 属性

用于警告被忽略的返回值(避免遗漏重要调用结果)。

[[nodiscard]] int compute() { return 42; }

compute(); // 可能被编译器警告

7. constexpr if 条件分支

用于模板上下文下的条件编译:

template<typename T>
void print(const T& val) {
    if constexpr (std::is_integral_v<T>)
        std::cout << "Integral: " << val << "\n";
    else
        std::cout << "Other: " << val << "\n";
}

二、标准库增强特性

1. std::optional

可选值容器,可能有值也可能无值。

std::optional<int> get() {
    if (...) return 42;
    return std::nullopt;
}

if (auto val = get(); val)
    std::cout << *val;

标准实现简析(摘自 libc++)

template <class T>
class optional {
    union { T val; };
    bool has_value;
};

2. std::variant

类型安全的联合体,支持多种类型之一。

std::variant<int, std::string> v = "text";
v = 10;

std::visit([](auto&& x) { std::cout << x; }, v);

使用索引或 std::get_if<T> 来访问成员。

3. std::any

可存放任意类型对象(运行时类型擦除)。

std::any a = 1;
a = std::string("test");

if (a.type() == typeid(std::string))
    std::cout << std::any_cast<std::string>(a);

本质上封装了类型信息和析构函数指针。

4. std::string_view

非拥有、只读的字符串视图(避免复制)。

void log(std::string_view msg) {
    std::cout << msg;
}

log("hello"); // 允许字面量

std::string 不兼容构造(不拥有内存)。

5. std::filesystem

跨平台文件与路径处理 API。

#include <filesystem>
namespace fs = std::filesystem;

if (fs::exists("data.txt")) {
    auto size = fs::file_size("data.txt");
}

常用操作:

  • 路径拼接:path / "file"
  • 遍历目录:directory_iterator

6. 并发相关改进

scoped_lock

用于一次加锁多个 mutex,避免死锁。

std::scoped_lock lock(m1, m2);  // 原子加锁

shared_mutex

提供多个读者 / 单个写者访问:

std::shared_mutex mutex;
{
    std::shared_lock lock(mutex);  // 多读
}
{
    std::unique_lock lock(mutex);  // 独写
}

三、实用工具类与函数

std::invoke

统一调用普通函数、成员函数、函数对象等:

std::invoke(f, args...);

std::apply

将 tuple 参数展开用于函数调用:

auto args = std::make_tuple(1, 2);
auto result = std::apply([](int a, int b) { return a + b; }, args);

四、总结

C++17 是一个“可用性增强”版本,重点改进包括:

  • 更简洁的语法(结构化绑定、折叠表达式)
  • 更强的模板条件表达能力(if constexpr)
  • 更高效的值类型容器(optional、variant)
  • 非拥有引用视图(string_view)
  • 正式的文件系统支持(filesystem)
  • 并发控制更安全(scoped_lock, shared_mutex)

相比 C++11/14,C++17 在实践中大幅降低了模板复杂度和常见代码样板,是现代 C++ 编程的推荐入门版本之一。

C++20 语言与标准库特性概览


一、语言核心特性

1. Concepts(概念)

对模板参数施加约束,提高模板可读性和错误提示质量。

template<typename T>
concept Number = std::is_arithmetic_v<T>;

template<Number T>
T add(T a, T b) { return a + b; }

2. 三向比较 <=>(Spaceship Operator)

统一实现 <, ==, > 等比较操作。

#include <compare>

struct Point {
    int x, y;
    auto operator<=>(const Point&) const = default;
};

3. Range-based 操作库(Ranges)

views:: 管道操作表达序列变换:

#include <ranges>

for (int i : std::views::iota(1, 10) | std::views::filter([](int x){ return x % 2 == 0; }))
    std::cout << i << " ";

4. 协程(Coroutines)

通过 co_await, co_yield, co_return 实现非阻塞协程。

task<int> foo() {
    co_return 42;
}

(完整协程需要协程 promise 和协程句柄封装)

5. 模块化(Modules)

替代头文件的模块系统(编译器支持有限)。

// math.ixx
export module math;
export int square(int x) { return x * x; }

使用:

import math;
int y = square(5);

6. constevalconstinit

consteval:必须在编译期求值

consteval int cube(int x) { return x * x * x; }

constinit:避免静态初始化顺序问题

constinit int global = 42;

7. 模板参数自动类型推导增强

支持 template<auto> 等:

template<auto N>
void printNTimes() { ... }

二、标准库增强特性

1. std::span

轻量非拥有数组视图,不拷贝数据。

void print(std::span<int> s) {
    for (int i : s) std::cout << i;
}

2. std::format

类似 Python f-string 的格式化输出:

#include <format>
std::cout << std::format("value = {}", 42);

3. std::ranges 模块

范围处理功能分为:

  • views::(惰性生成、变换)
  • actions::(修改容器)
  • ranges::(通用接口)
auto evens = vec | std::views::filter([](int x){ return x % 2 == 0; });

4. std::concepts(标准概念)

如:

  • std::same_as<T, U>
  • std::convertible_to<T, U>
  • std::invocable<F, Args...>

示例:

template<std::integral T>
void f(T x);

5. std::bit_cast

类型安全的强制转换(要求类型大小相同):

float f = 3.14f;
uint32_t u = std::bit_cast<uint32_t>(f);

6. std::jthread

自动 join 的线程:

std::jthread t([] { do_work(); });  // 析构时自动 join

7. std::stop_token / stop_source

用于线程取消协作机制:

void run(std::stop_token st) {
    while (!st.stop_requested()) { ... }
}

8. std::move_only_function

仅可移动的泛型可调用封装器:

std::move_only_function<void()> f = [] { ... };

三、总结

C++20 是继 C++11 之后又一次大的语言升级,核心目标包括:

  • 提升模板约束与泛型表达力(Concepts)
  • 引入现代并发与异步控制(Coroutines、jthread、stop_token)
  • 引入模块系统,重构头文件机制(Modules)
  • 扩展标准库以支持更现代的开发风格(ranges、format、span)
  • 提供更强的编译期工具(consteval、constinit、bit_cast)

C++20 被广泛认为是现代 C++ 成熟阶段的重要标志。

C++23 语言与标准库特性概览


一、语言核心特性

1. 显式 this 参数

允许将 this 作为显式形参,支持更灵活的成员函数调用。

struct S {
    void func(this S& self, int x) {
        self.value = x;
    }
    int value;
};

2. 多维下标运算符

用户自定义类型支持 operator[](...) 形式的多维下标。

struct Matrix {
    int operator[](size_t i, size_t j) const;
};

3. if consteval

用于判断当前是否处于 consteval 上下文。

consteval int always_constexpr() { return 1; }

constexpr int f() {
    if consteval {
        return always_constexpr();
    } else {
        return 0;
    }
}

4. 模板函数形参列表中的别名声明

可直接引入类型别名:

template<typename T>
void f(alias A = typename T::value_type);

5. lambda 表达式支持属性与显式模板

auto l = []<typename T>(T x) [[nodiscard]] { return x + 1; };

6. 默认构造函数推导改进(CTAD)

类模板参数推导支持更多构造情况,提升推导准确性。

7. 推导指南中支持 requires

template<typename T>
requires std::integral<T>
struct X { T value; };

X x = X{42};  // 仅当 T 满足 integral

二、标准库增强特性

1. std::expected<T, E>

用于替代 std::optional 表达错误信息:

std::expected<int, std::string> parse(std::string_view s);

if (res) {
    int val = *res;
} else {
    std::cerr << res.error();
}

2. std::print, std::println

std::format 结合的便捷输出:

std::print("value: {}
", 42);
std::println("hello {}", "world");

3. std::generator

轻量协程生成器:

std::generator<int> gen() {
    for (int i = 0; i < 3; ++i)
        co_yield i;
}

4. std::move_only_function

泛型回调对象,移动语义支持:

std::move_only_function<void()> f = [] { do_something(); };

5. std::flat_map, std::flat_set(尚未进入主线实现,但已提案接受)

排序向量构造的 map/set,插入慢、查找快、占用小。

6. std::stacktrace

用于捕获运行时调用栈信息:

auto st = std::stacktrace::current();
std::cout << st;

三、语言细节改进

  • constexpr 支持更多 STL 类型(如 std::vector 部分操作)
  • operator[]constexpr
  • static operator() / static [](实验性提案)
  • UTF-8 字符串视为 portable 源代码字符集

四、总结

C++23 是对 C++20 的迭代补强版本,主要特点是:

  • 拓展已有语言特性表达力(lambda、requires、模板)
  • 提供更强的工具类型支持(expected、generator)
  • 增强标准库的诊断与调试能力(stacktrace)
  • 强化 constexpr 在语言与标准库中的一致性

C++23 兼容性强,适用于需要现代语法同时强调编译期控制与运行期效率的项目。

C++20 协程与可等待对象


一、协程基本概念

C++20 协程是一种语言扩展,支持将函数挂起并在未来恢复,适用于异步 IO、生成器、状态机等场景。

关键保留字:

  • co_await: 暂停并等待一个 awaitable 对象完成
  • co_yield: 生成一个值,挂起协程(用于生成器)
  • co_return: 从协程返回值

二、协程执行原理简述

一个使用 co_await, co_yield, co_return 的函数会被编译器转换为状态机,其返回类型必须满足如下接口:

协程需要的类型

  • promise_type
  • handle_type = std::coroutine_handle<promise_type>
  • 必须定义:
    • promise_type::get_return_object()
    • promise_type::initial_suspend()
    • promise_type::final_suspend()
    • promise_type::return_void()return_value()
    • promise_type::unhandled_exception()

三、协程最小实现示例

#include <coroutine>
#include <iostream>

struct Task {
    struct promise_type {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};

Task simple() {
    std::cout << "In coroutine
";
    co_return;
}

int main() {
    simple();
}

该协程立即执行,不挂起。若需挂起/恢复,需定义 std::suspend_alwaysstd::suspend_never 以控制。


四、可等待对象(Awaitable)

满足以下接口即可被 co_await

  • await_ready() → bool:是否立即完成(返回 true 则不挂起)
  • await_suspend(std::coroutine_handle<>):挂起行为,传入当前协程句柄
  • await_resume():恢复后返回值

示例:自定义 awaitable

struct Awaiter {
    bool await_ready() const noexcept { return false; }

    void await_suspend(std::coroutine_handle<> h) const {
        std::cout << "Suspending
";
        h.resume();  // 立即恢复
    }

    int await_resume() const noexcept {
        return 42;
    }
};

Task example() {
    int val = co_await Awaiter{};
    std::cout << "Got " << val << "
";
}

五、生成器示例(使用 co_yield)

#include <coroutine>
#include <iostream>

template<typename T>
struct Generator {
    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;

    struct promise_type {
        T value;
        Generator get_return_object() { return Generator{handle_type::from_promise(*this)}; }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(T v) {
            value = v;
            return {};
        }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    handle_type h;
    Generator(handle_type h): h(h) {}
    ~Generator() { if (h) h.destroy(); }

    bool next() {
        h.resume();
        return !h.done();
    }

    T current_value() const {
        return h.promise().value;
    }
};

Generator<int> gen() {
    for (int i = 0; i < 3; ++i)
        co_yield i;
}

int main() {
    auto g = gen();
    while (g.next()) {
        std::cout << g.current_value() << "
";
    }
}

六、与线程协作:std::jthread + 协程挂起

结合 std::stop_token 实现线程取消:

struct SleepAwaitable {
    std::chrono::milliseconds dur;
    bool await_ready() { return false; }

    void await_suspend(std::coroutine_handle<> h) {
        std::thread([h, d=dur]() {
            std::this_thread::sleep_for(d);
            h.resume();
        }).detach();
    }

    void await_resume() {}
};

使用:

Task delayed() {
    std::cout << "Wait...
";
    co_await SleepAwaitable{1s};
    std::cout << "Done
";
}

七、标准库协程类介绍(C++23 起)

  • std::generator<T>:标准生成器
  • std::task<T>(未来 TS):表示可等待任务
  • std::sync_wait, std::when_all 等协程组合工具(需配合执行器实现)

八、总结

C++ 协程机制高度底层,编译器将其转换为状态机,通过 promise_typecoroutine_handle 控制生命周期。

使用协程应理解:

  • 协程自身是惰性的(不会自动运行)
  • co_await 本质是协程挂起点,依赖被等待对象是否决定挂起
  • 可构建生成器、异步调度器、future 框架等多种抽象

建议实践中使用封装好的框架如 cppcorolibunifex,以避免手动实现完整的状态管理。

C++23新特性:智能指针与C风格出参的优雅交互 - out_ptrinout_ptr详解

前言

在C++编程中,智能指针(如 std::unique_ptrstd::shared_ptr)是管理动态内存、防止资源泄漏的重要工具。然而,当C++代码需要与传统的C风格API或一些期望通过输出参数(通常是 T**T*&)来分配或修改指针的库交互时,智能指针的非侵入式特性会带来一些不便和潜在风险。C++23引入的 std::out_ptrstd::inout_ptr 适配器旨在优雅地解决这一问题。

1. 问题的提出:智能指针与C风格出参的矛盾

1.1 C风格接口的挑战

当我们使用智能指针时,获取其管理的裸指针通常通过 get() 方法或解引用操作符 *。这对于接受 T* 参数(且不获取所有权)的C风格函数非常方便:

// C风格函数,不获取所有权
void ProcessObject(Foo* ptr);

// C++代码
std::unique_ptr<Foo> smart_foo = std::make_unique<Foo>();
ProcessObject(smart_foo.get()); // 安全且方便

然而,问题出现在当C风格API期望通过一个指向指针的指针(T**)或指针的引用(T*&)来返回一个新分配的对象或修改一个已有的指针时:

// C风格函数,通过出参分配新对象
bool CreateObject(Foo** ppObject);

// C风格函数,可能释放旧对象并分配新对象
bool RefreshObject(Foo*& pObject); // 或 Foo** pObject

直接将智能指针用于此类接口会导致编译错误,因为智能指针不提供直接获取其内部裸指针地址的操作。传统的C++做法是:

std::unique_ptr<Foo> sp_foo;
Foo* raw_ptr = nullptr;

if (CreateObject(&raw_ptr)) {
    sp_foo.reset(raw_ptr); // raw_ptr现在由智能指针管理
} else {
    // 如果CreateObject内部只分配了内存但返回false,raw_ptr可能需要手动delete
    // delete raw_ptr; // 视CreateObject的具体行为而定
}

这种手动管理的方式存在一个资源泄漏的风险窗口:在 CreateObject 成功分配 raw_ptr 之后,到 sp_foo.reset(raw_ptr) 执行之前,如果发生任何异常,raw_ptr 所指向的内存将无法被 unique_ptr 接管,从而导致泄漏。

1.2 传统的代理类方案及其局限性

为了解决上述问题,一种常见的模式是使用一个临时的代理(Proxy)对象。这个代理对象在其构造函数中接收智能指针的引用,并提供一个可以隐式转换为 T** 的接口。在其析构函数中,它会将通过C API获取到的裸指针 reset 给智能指针。

一个简化的 UniquePtrProxy 可能如下:

template<typename T>
struct UniquePtrProxy {
    std::unique_ptr<T>& m_output_sp; // 引用待更新的智能指针
    T* m_raw_ptr_cache = nullptr;  // 临时存储C API返回的裸指针

    explicit UniquePtrProxy(std::unique_ptr<T>& sp) : m_output_sp(sp) {}

    // RAII:确保在代理对象生命周期结束时,智能指针被更新
    ~UniquePtrProxy() {
        if (m_raw_ptr_cache) { // 只有当C API确实输出了指针才reset
            m_output_sp.reset(m_raw_ptr_cache);
        }
    }

    // 禁止拷贝和赋值
    UniquePtrProxy(const UniquePtrProxy&) = delete;
    UniquePtrProxy& operator=(const UniquePtrProxy&) = delete;

    // 允许隐式转换为 T**,供C API使用
    operator T**() {
        // C API可能会写入新的指针到m_raw_ptr_cache
        // 如果智能指针已有对象,其所有权会在此处丢失,除非C API负责释放
        // 或者像std::inout_ptr那样,在转换前release()
        return &m_raw_ptr_cache;
    }

    // 对于某些需要 T*& 的API (更复杂,通常inout_ptr更适合)
    operator T*&() {
        m_raw_ptr_cache = m_output_sp.release(); // 智能指针释放所有权
        return m_raw_ptr_cache; // C API可以直接修改这个裸指针
    }
};

使用起来可能是这样:

std::unique_ptr<Foo> sp_foo;
if (CreateObject(UniquePtrProxy<Foo>(sp_foo))) {
    // 使用 sp_foo
}

这种代理模式利用RAII特性,在代理对象(通常是临时对象)生命周期结束时自动更新智能指针,从而缩小了资源泄漏的风险窗口。

局限性: 这种临时代理对象的方式有一个经典的陷阱,即临时对象的生命周期问题。考虑以下代码:

std::unique_ptr<Foo> sp_foo;
// 错误示例:临时对象生命周期问题
if (CreateObject(UniquePtrProxy<Foo>(sp_foo)) && sp_foo) { // 检查sp_foo是否有效
    // ...
}

在C++中,UniquePtrProxy<Foo>(sp_foo) 创建的临时对象,其析构(即sp_foo.reset()的调用)通常会延迟到包含该临时对象的完整表达式(整个 if 语句条件)求值完毕之后。这意味着,在 && sp_foo 这个子表达式被求值时,sp_foo 还没有被 UniquePtrProxy 的析构函数所更新。因此,即使 CreateObject 成功,sp_foo 在检查时仍然是空的,导致 if 块永远不会执行。

2. C++23 智能指针适配器

C++23标准库提供了官方的解决方案:std::out_ptr_tstd::inout_ptr_t 适配器类,以及对应的辅助函数 std::out_ptrstd::inout_ptr

2.1 std::out_ptr_tstd::inout_ptr_t (适配器类)

  • std::out_ptr_t<SmartPointer, Pointer, Args...>: 用于纯输出参数的场景,即C API会分配新的对象,并将指针写入提供的 Pointer* (通常是 T**)。智能指针 SmartPointer 在此操作前应为空,或其原有对象会被正确处理(通常是先 release()reset())。

    std::unique_ptr<Foo> sp_foo;
    // 显式使用适配器类,模板参数较多
    if (CreateObject(std::out_ptr_t<std::unique_ptr<Foo>, Foo*>(sp_foo))) {
        if (sp_foo) {
            // 使用 sp_foo
        }
    }
  • std::inout_ptr_t<SmartPointer, Pointer, Args...>: 用于输入/输出参数的场景。C API可能会读取传入的指针,释放其指向的资源,然后分配新的资源并更新指针。这个适配器通常会先调用智能指针的 release() 方法,将所有权转移给一个临时裸指针,传递给C API,然后在适配器析构时用C API返回的新指针(或原指针,如果未改变)重新构建智能指针。

    std::unique_ptr<Foo> sp_foo = std::make_unique<Foo>(/*..initial_args..*/);
    // 显式使用适配器类
    // 假设RefreshObject是 Foo*& 类型,并且会先delete传入的指针,再分配新的
    if (RefreshObject(std::inout_ptr_t<std::unique_ptr<Foo>, Foo*>(sp_foo))) {
        if (sp_foo) {
            // 使用 sp_foo
        }
    }

2.2 辅助函数 std::out_ptrstd::inout_ptr

为了简化 std::out_ptr_tstd::inout_ptr_t 的使用,标准库提供了模板辅助函数,它们可以根据传入的智能指针类型自动推导模板参数:

  • std::out_ptr(SmartPointer& sp, Args&&... args): 返回 std::out_ptr_t 对象。
  • std::inout_ptr(SmartPointer& sp, Args&&... args): 返回 std::inout_ptr_t 对象。

使用辅助函数,代码变得更简洁:

// 使用 std::out_ptr
std::unique_ptr<Foo> sp_foo_out;
if (CreateObject(std::out_ptr(sp_foo_out))) { // 假设CreateObject接受 Foo**
    if (sp_foo_out) { /* ... */ }
}

// 使用 std::inout_ptr
std::unique_ptr<Foo> sp_foo_inout = std::make_unique<Foo>();
// 假设RefreshObject接受 Foo*&
// 注意:std::inout_ptr(sp_foo_inout)的转换操作符会返回一个Foo**类型,
// 如果RefreshObject严格要求Foo*&,可能需要一个更特定的适配器或API调整。
// 标准库的std::inout_ptr主要还是针对 T**。
// 如果RefreshObject是 void RefreshObject(Foo** ppFoo) 类型:
if (RefreshObject(std::inout_ptr(sp_foo_inout))) { 
    if (sp_foo_inout) { /* ... */ }
}

2.3 使用注意事项

  1. 临时对象的生命周期: 与传统的代理类方案类似,std::out_ptrstd::inout_ptr 返回的临时适配器对象,其生命周期问题依然存在。如下代码仍然是错误的:

    std::unique_ptr<Foo> sp_foo;
    // 错误:sp_foo在条件判断时尚未被更新
    if (CreateObject(std::out_ptr(sp_foo)) && sp_foo) {
        // ...
    }

    正确的做法是分步:

    std::unique_ptr<Foo> sp_foo;
    bool success = CreateObject(std::out_ptr(sp_foo)); // 适配器在此完整表达式结束后析构
    if (success && sp_foo) { // 此处sp_foo已被正确更新
        // ...
    }
  2. 避免延长临时适配器对象的生命周期: 不要使用 auto&& 或其他方式来延长 std::out_ptr_t / std::inout_ptr_t 临时对象的生命周期,这会导致其析构延迟,使得智能指针的更新晚于预期。

    std::unique_ptr<Foo> sp_foo;
    // 错误:rrr延长了临时对象的生命周期
    auto&& rrr = std::out_ptr(sp_foo); 
    if (CreateObject(rrr)) { 
        // 此时sp_foo仍为空,因为rrr还未析构
        if (sp_foo) { /* ... */ } 
    }
    // rrr在此处(或作用域结束时)析构,sp_foo才被更新
  3. std::inout_ptr_tstd::shared_ptrstd::inout_ptr_t 的行为是先释放智能指针原有的所有权,然后用C API返回的指针重新初始化。这种操作模式与 std::shared_ptr 的共享所有权语义不兼容,因此 std::inout_ptr_t (及 std::inout_ptr) 不能用于 std::shared_ptrstd::out_ptr_t 可以用于空的 std::shared_ptr

  4. C API的指针处理语义: 使用这些适配器前,必须清楚C API对于传入的 T**T*& 参数是如何操作的:

    • 对于 out_ptr:API是否总是分配新内存?API是否会处理(如 delete)原先通过 T** 传入的非空指针?标准 out_ptr 的行为是先 reset() 智能指针,所以如果C API不处理传入的指针,而智能指针原来管理着一个对象,该对象会被释放。
    • 对于 inout_ptr:API是否会 deletefree 传入的指针所指向的对象?如果API不释放,适配器在 release() 后将所有权交给临时裸指针,C API操作后,适配器析构时会用新指针 reset 智能指针。如果C API没有释放原对象且也没有返回新对象(而是修改了原对象),则需要确保所有权被正确传递。

3. 核心优势与总结

std::out_ptrstd::inout_ptr 适配器的引入,为C++开发者带来了诸多好处:

  • 安全性增强:通过RAII机制,确保从C API获取的资源能够被智能指针正确接管,极大地降低了因异常或编码疏忽导致的资源泄漏风险。
  • 代码简洁性:相比手动管理裸指针或编写自定义代理类,使用标准库提供的适配器使得与C风格API交互的代码更加简洁明了。
  • 标准化:提供了一套标准的、通用的解决方案,提高了代码的可移植性和可维护性。
  • 与C++生态的融合:更好地将C风格的资源获取/释放模式整合到现代C++的RAII和智能指针体系中。

4. 示例代码片段回顾

使用 std::out_ptr (配合 T** 参数的C API)

// C API: bool MakeObject(Foo** ppObject);
std::unique_ptr<Foo> sp_foo;
bool success = MakeObject(std::out_ptr(sp_foo)); // 适配器在此完整表达式结束后析构
if (success) { 
    if (sp_foo) { // sp_foo已被更新
        std::cout << "Object created, value = " << sp_foo->GetValue() << std::endl;
    }
}

使用 std::inout_ptr (配合 T** 修改型参数的C API)

假设 RefreshObject(Foo** ppObject) 会读取 *ppObject,可能会释放它,然后将 *ppObject 指向新分配的对象。

// C API: bool RefreshObject(Foo** ppObject);
std::unique_ptr<Foo> sp_foo = std::make_unique<Foo>("initial_data");
Foo* old_raw_ptr = sp_foo.get(); // 仅为观察

// std::inout_ptr(sp_foo)会做类似:
// 1. temp_raw_ptr = sp_foo.release(); (sp_foo变为空,所有权交给temp_raw_ptr)
// 2. RefreshObject(&temp_raw_ptr); (C API操作temp_raw_ptr)
// 3. sp_foo.reset(temp_raw_ptr); (适配器析构时,sp_foo接管新/修改后的指针)
bool refreshed = RefreshObject(std::inout_ptr(sp_foo));
if (refreshed) {
    if (sp_foo) {
        std::cout << "Object refreshed, new value = " << sp_foo->GetValue() << std::endl;
        if (sp_foo.get() != old_raw_ptr) {
            std::cout << "Pointer was changed by RefreshObject." << std::endl;
        }
    }
}

5. 结语

std::out_ptrstd::inout_ptr 是C++23中非常实用的工具,它们弥合了现代C++智能指针与传统C风格API在指针管理上的鸿沟。正确理解和使用这些适配器,能够显著提升代码的健壮性和简洁性,尤其是在与大量遗留C代码或底层库交互的场景中。务必注意其生命周期和与特定智能指针(如 shared_ptr)的兼容性问题,以及C API的具体行为,以发挥其最大效用。

C++类型擦除技术深度解析:概念、实践与优化

1. 理解类型擦除(Type Erasure)

1.1 核心概念

C++作为一种静态强类型语言,在编译期就确定了大部分类型信息,这带来了性能和安全上的优势。然而,在某些设计场景下,过于严格的类型约束反而会限制代码的灵活性和通用性。类型擦除(Type Erasure)技术应运而生,它允许我们编写能够操作多种不同具体类型的通用代码,而这些通用代码仅关注这些类型共有的、符合某种抽象定义的“特定行为”,仿佛这些类型的其他特有部分被“擦除”了一样。

实现类型擦除的两个关键要素:

  1. 特定行为的抽象:定义一组操作或接口,这些是通用代码所依赖的。
  2. 具体类型的隐藏:通用代码不直接依赖于某个具体类型,而是通过上述抽象接口与之交互,从而隐藏了底层的具体实现类型。

1.2 优缺点分析

优点:

  • 通用性与灵活性:允许使用统一的接口处理不同类型的对象,增强代码的复用性和适应性。
  • 封装与解耦:隐藏具体类型的实现细节,减少公有接口的类型暴露,使得代码更简洁,依赖关系更清晰,提高可维护性。
  • 支持多态:无论是运行时多态(通过虚函数)还是静态多态(通过模板),类型擦除都能提供一种实现方式,使得存储和操作异构对象集合成为可能。

缺点:

  • 运行时开销:通常需要额外的间接层(如虚函数调用、指针解引用、模板实例化)来实现类型封装和行为转发,可能引入性能开销。
  • 类型安全性的妥协:编译期类型检查的能力减弱。虽然我们试图通过抽象来约束行为,但有时仍需在运行时进行类型检查(如 std::any_cast)以确保操作的正确性,这可能导致运行时错误。
  • 调试困难:类型信息的隐藏可能使得调试过程更加复杂,因为在通用代码层面不易直接观察到具体对象的内部状态。

1.3 适用场景与设计特点

类型擦除技术特别适用于以下场景:

  • 代码逻辑依赖于对象的一组特定行为,而非其完整的类型信息。
  • 存在多种具体类型,它们都实现了这组特定的行为。
  • 希望隐藏这些具体类型中与特定行为无关的其他部分。

采用类型擦除技术的设计通常具备以下特点:

  • 通用代码不依赖于被擦除的具体类型
  • 在执行特定行为的通用代码中,具体类型被隐藏
  • 特定行为通过一个类型不可知(Type-Agnostic)的接口被调用。
  • 调用点是最后知道具体类型的地方,在该点,具体类型被转换为抽象接口。
  • 当需要访问具体类型的非特定行为时,可能需要类型具化(Type Recovery)操作。

1.4 C语言中的经典案例:qsort()

C标准库中的 qsort() 函数是类型擦除的一个早期体现:

void qsort(void *base, size_t nmemb, size_t size, 
           int (*compare)(const void *, const void *));
  • qsort() 的排序算法本身不关心待排序元素的具体类型,通过 void* 隐藏了类型信息。
  • 它依赖于调用者提供的 compare 函数(特定行为的抽象)来确定元素间的顺序。
  • 调用 qsort() 的地方(调用点)知道数组元素的具体类型,并负责提供与该类型匹配的比较函数。当 compare 函数被回调时,需要将 void* 具化回原始类型进行比较。
int less_int(const void *lhs, const void *rhs) {
    return *(const int*)lhs - *(const int*)rhs; 
}

int arr[8] = { 1, 8, 4, 7, 6, 2, 3, 9 };
qsort(arr, sizeof(arr)/sizeof(arr[0]), sizeof(arr[0]), less_int);

虽然 void* 在C++中通常被认为是不够类型安全的,但 qsort() 的设计思想体现了类型擦除的基本原理。

2. C++中的类型擦除实践

2.1 传统面向对象方法:“接口+实现”

这是最常见的基于继承和虚函数的多态实现,也可以看作一种类型擦除。

// 抽象基类(接口)
class Shape {
public:
    virtual ~Shape() = default; // 重要:虚析构函数
    virtual void Draw(const Context& ctx) const = 0;
};

// 具体实现
class Rectangle : public Shape {
public:
    Rectangle(double w, double h) { /* ... */ }
    void Draw(const Context& ctx) const override { /* ... */ }
};

class Circle : public Shape {
public:
    Circle(double r) { /* ... */ }    
    void Draw(const Context& ctx) const override { /* ... */ }    
};

// 通用代码
void DrawAllShapes(const std::vector<Shape*>& shapes) {
    Context ctx{ /* ... */ };
    for(const auto* shape_ptr : shapes) {
        if (shape_ptr) {
            shape_ptr->Draw(ctx); // 通过基类指针调用虚函数,具体类型被"擦除"
        }
    }
}

存在的问题与反思

  1. 设计耦合:将 Draw 方法作为 Shape 的一部分,使得所有形状都必须依赖 Context。如果图形绘制逻辑并非形状的核心职责,这种设计可能不佳。
  2. 侵入性:外部类型(如隔壁老王写的 Triangle 类)必须继承自 Shape 才能被 DrawAllShapes 处理,这限制了代码复用。
  3. 接口膨胀:若要为 Shape 增加新行为(如 Serialize),就需要修改基类 Shape,违反了开闭原则(OCP),并可能影响整个继承体系。
  4. 值语义支持不佳:通常需要通过指针或引用来操作对象以实现多态,避免对象切片。返回类型擦除后的对象也往往只能是指针或引用,带来生命周期管理的复杂性。

2.2 基于模板的外部多态 (Non-intrusive Type Erasure)

现代C++更倾向于使用模板来实现非侵入式的类型擦除,它不要求具体类型继承自某个公共基类。

2.2.1 简单的模板泛化(非多态容器)

// 具体类型,无需共同基类
class Rectangle { /* ... */ };
class Circle { /* ... */ };

// 针对具体类型的独立绘制函数 (特定行为)
void DrawShape(const Context& ctx, const Circle& circle);
void DrawShape(const Context& ctx, const Rectangle& rect);

// 通用代码 (通过模板参数擦除类型)
template <typename ShapeType>
void DrawSingleShape(const ShapeType& shape) {
    Context ctx{ /* ... */ };
    DrawShape(ctx, shape); // 依赖于ShapeType存在匹配的DrawShape重载
}

这种方法擦除了 DrawSingleShape 内部对具体类型的依赖,但无法将不同类型的 ShapeType 存储在同一个异构容器中(如 std::vector),因此不是真正的运行时多态容器。

2.2.2 类型擦除容器 (Type Erasure Idiom / Wrapper)

这是一种更强大的技术,它创建一个包装类(Wrapper),该包装类对外提供统一的接口,内部则通过PIMPL(Pointer to Implementation)模式或类似机制来持有一个指向“概念模型”的指针,该模型再间接持有所包装的具体类型对象。

Klaus Iglberger 在 CppCon 2022 的演讲中展示了这种思路。下面是一个简化的示例:

// 假设独立的绘制函数已存在
// void DrawShape(const Context& ctx, const ConcreteShape& shape);

class ShapeWrapper {
private:
    // 1. 行为概念接口 (Concept Interface)
    struct ShapeConcept {
        virtual ~ShapeConcept() = default;
        virtual void DoDraw(const Context& ctx) const = 0;
        // 如果需要支持拷贝,还需要虚克隆方法
        virtual std::unique_ptr<ShapeConcept> Clone() const = 0; 
    };

    // 2. 具体类型模型 (Model) - 模板类,适配具体类型到Concept接口
    template<typename ConcreteShape>
    struct ShapeModel : public ShapeConcept {
        ConcreteShape m_shape_object; // 持有具体类型的对象

        ShapeModel(ConcreteShape shape) : m_shape_object(std::move(shape)) {}

        void DoDraw(const Context& ctx) const override {
            DrawShape(ctx, m_shape_object); // 将调用转发给具体类型的DrawShape
        }

        std::unique_ptr<ShapeConcept> Clone() const override {
            return std::make_unique<ShapeModel<ConcreteShape>>(m_shape_object); // 实现拷贝
        }
    };

    std::unique_ptr<ShapeConcept> m_pimpl; // PIMPL 指针

public:
    // 模板构造函数,接受任意类型,并创建对应的Model
    template<typename ConcreteShape>
    ShapeWrapper(ConcreteShape shape) 
        : m_pimpl(std::make_unique<ShapeModel<ConcreteShape>>(std::move(shape))) {}

    // 拷贝构造函数 (如果支持)
    ShapeWrapper(const ShapeWrapper& other) : m_pimpl(other.m_pimpl ? other.m_pimpl->Clone() : nullptr) {}

    // 移动构造函数
    ShapeWrapper(ShapeWrapper&& other) noexcept = default;
    
    // 拷贝赋值 (如果支持)
    ShapeWrapper& operator=(const ShapeWrapper& other) {
        if (this != &other) {
            m_pimpl = (other.m_pimpl ? other.m_pimpl->Clone() : nullptr);
        }
        return *this;
    }
    
    // 移动赋值
    ShapeWrapper& operator=(ShapeWrapper&& other) noexcept = default;
    
    // 公共接口,调用PIMPL的虚方法
    void Draw(const Context& ctx) const {
        if (m_pimpl) {
            m_pimpl->DoDraw(ctx);
        }
    }
};

现在,我们可以将不同类型的对象存储在 ShapeWrapper 的容器中:

// 假设Triangle类和对应的DrawShape(ctx, Triangle)也已定义
class Triangle { /* ... */ };
void DrawShape(const Context& ctx, const Triangle& triangle);

void DrawAllWrappedShapes(const std::vector<ShapeWrapper>& shapes) {
    Context ctx{ /* ... */ };
    for(const auto& shape_wrapper : shapes) {
        shape_wrapper.Draw(ctx); // 调用的是ShapeWrapper::Draw
    }   
}

int main() {
    std::vector<ShapeWrapper> shapes;
    shapes.emplace_back(Circle{5.8});
    shapes.emplace_back(Rectangle{15.0, 22.0});
    shapes.emplace_back(Triangle{/*...*/}); // 阿猫阿狗的Triangle也能用

    DrawAllWrappedShapes(shapes);
    return 0;
}

优点

  • 非侵入式Circle, Rectangle, Triangle 无需继承共同基类。
  • OCP友好:易于扩展以支持新的形状类型,只需确保存在对应的 DrawShape 函数。
  • 值语义ShapeWrapper 可以(如果实现了 Clone)支持值拷贝,避免了裸指针管理。
  • 编译防火墙:增加新形状类型通常不需重新编译大量依赖 ShapeWrapper 的代码。

潜在问题与改进

  1. 行为硬编码ShapeModel 中对 DrawShape(ctx, m_shape_object) 的调用是硬编码的。如果不同类型有不同名称的绘制方法,或参数略有差异,就需要更复杂的适配。
    • 解决方案:可以使用 std::function 存储具体行为,并在 ShapeWrapper 构造时传入可调用对象。或者使用更高级的元编程技巧。
  2. 接口扩展:若要 ShapeWrapper 支持更多行为(如 Serialize),仍需修改 ShapeConcept 和所有 ShapeModel 特化。
    • 解决方案:可以设计更通用的 ShapeConcept,或者如原文提及,通过多重继承等方式扩展 ShapeConcept 的能力。

2.3 标准库中的类型擦除工具

  • std::function: 封装任意可调用对象(函数指针、lambda、仿函数等),对外提供统一的调用接口,擦除了具体的可调用对象类型。
  • std::any (C++17): 可以存储任意可拷贝构造类型(CopyConstructible)的单个值。配合 std::any_cast 进行类型安全的取回。它本身就是一种类型擦除容器。
#include <functional>
#include <any>
#include <iostream>
#include <string>
#include <vector>

void print_int(int i) { std::cout << "int: " << i << std::endl; }
void print_string(const std::string& s) { std::cout << "string: " << s << std::endl; }

int main() {
    // std::function
    std::vector<std::function<void(int)>> callables;
    callables.push_back(print_int);
    callables.push_back([](int x){ std::cout << "lambda: " << x * x << std::endl; });
    for (auto& func : callables) { func(5); }

    // std::any
    std::vector<std::any> items;
    items.push_back(10);
    items.push_back(std::string("hello"));
    items.push_back(3.14f);

    for (const auto& item : items) {
        if (item.type() == typeid(int)) {
            std::cout << "any (int): " << std::any_cast<int>(item) << std::endl;
        } else if (item.type() == typeid(std::string)) {
            std::cout << "any (string): " << std::any_cast<const std::string&>(item) << std::endl;
        } // ...
    }
    return 0;
}
  • Boost.TypeErasure: 一个功能更强大、更完善的第三方类型擦除库,提供了更灵活的概念定义和模型生成机制。

3. 类型擦除的相关议题

3.1 对象复制

类型擦除后的对象复制是个挑战,因为构造函数不能是虚的。

  • Clone模式 (原型模式):在 ShapeConcept 中添加一个虚的 Clone() 方法,由 ShapeModel 实现具体的克隆逻辑。这是实现值语义拷贝的常用方式。
    // 在ShapeConcept中:
    // virtual std::unique_ptr<ShapeConcept> Clone() const = 0;
    // 在ShapeModel中:
    // std::unique_ptr<ShapeConcept> Clone() const override {
    //     return std::make_unique<ShapeModel<ConcreteShape>>(m_shape_object); // 依赖ConcreteShape的拷贝构造
    // }
    // 在ShapeWrapper中:
    // ShapeWrapper(const ShapeWrapper& other) : m_pimpl(other.m_pimpl ? other.m_pimpl->Clone() : nullptr) {}

3.2 鸭子类型 (Duck Typing)

“如果它像鸭子一样走路,像鸭子一样嘎嘎叫,那它可能就是一只鸭子。” —— 鸭子类型关注对象的行为而非其继承关系。C++模板使得静态鸭子类型成为可能,结合类型擦除,可以实现运行时的外部多态。

3.3 优化

类型擦除容器通常基于值语义设计,其拷贝、移动和构造的性能至关重要。

  • 小对象优化 (Small Object Optimization, SOO):也称本地缓冲区优化 (Small Buffer Optimization, SBO)。当被包装的对象体积较小时,直接存储在包装类内部的缓冲区中,避免动态内存分配。std::functionstd::string (在某些实现中) 都使用了此技术。
  • 写时拷贝 (Copy-On-Write, COW):延迟实际的拷贝操作直到对象被修改时。在多线程环境下实现复杂,且现代C++中因移动语义的普及已较少推荐。
  • C++20 Concepts:可以用来约束模板参数,确保传入类型满足特定行为的编译期要求,增强类型安全性和代码可读性。
    template<typename T>
    concept HasDrawMethod = requires(T t, const Context& ctx) {
        { t.Draw(ctx) } -> std::same_as<void>; // 假设Draw方法返回void
    };
    
    template<HasDrawMethod ShapeType> // 使用Concept约束
    struct ShapeModel : public ShapeConcept {
        // ...
        void DoDraw(const Context& ctx) const override {
            m_shape_object.Draw(ctx); // 直接调用,因为Concept保证了其存在
        }
        // ...
    };

4. 总结

类型擦除是一种强大的设计技术,通过在适当的抽象层级隔离依赖关系,来应对软件设计中的变化。它允许我们创建灵活的、可扩展的系统,能够处理异构类型的对象集合,而无需强制它们继承自共同的基类。基于模板的外部多态是现代C++中实现类型擦除的常用且高效的方法,它能有效避免传统继承体系的弊端,如侵入性、接口膨胀和编译时依赖过重。结合小对象优化、概念等技术,可以构建出既灵活又高效的类型擦除解决方案。

C++17核心特性:类模板实参推导 (CTAD) 详解

1. 引言:为何需要CTAD?

在C++17之前,实例化一个类模板时,程序员通常需要显式指定所有的模板参数,即使这些参数可以从构造函数的参数中推断出来。例如:

std::pair<int, double> p(2, 4.5);
std::tuple<int, int, double> t(4, 3, 2.5);

这无疑增加了代码的冗余度。为了简化模板类的使用,C++17引入了类模板实参推导(Class Template Argument Deduction, CTAD)。CTAD允许编译器在特定上下文中,根据初始化器(initializer)的类型自动推导出缺失的模板实参。

2. CTAD的应用场景

CTAD主要在以下几种语境中生效:

2.1 变量及变量模板的初始化声明

当声明一个类模板类型的变量(可以带cv限定符)并进行初始化时,如果只提供了类模板名而没有模板实参列表,编译器会尝试推导。

std::pair p(2, 4.5);     // 编译器推导出 std::pair<int, double> p(2, 4.5);
std::tuple t(4, 3, 2.5); // 类似于 auto t = std::make_tuple(4, 3, 2.5);
                         // 推导出 std::tuple<int, int, double>
std::less l;             // 对于无参构造,如std::less,会推导为 std::less<void> l;

2.2 new表达式

当使用new表达式创建类模板对象时,如果类型只指定了模板名,编译器会进行推导。

template<class T>
struct A {
    A(T, T);
};
 
auto y = new A{1, 2}; // 分配的类型被推导为 A<int>

2.3 函数式转换表达式 (Functional Cast)

在函数式风格的类型转换(也常用于对象构造)中,如果目标类型是类模板名,会触发CTAD。

std::mutex mtx;
auto lck = std::lock_guard(mtx);     // 推导出 std::lock_guard<std::mutex>

std::vector<int> vi1 = {1,2,3}, vi2;
std::copy_n(vi1.begin(), 3,
    std::back_insert_iterator(vi2)); // 推导出 std::back_insert_iterator<std::vector<int>>
                                     // (T 是容器 vi2 的类型)

auto my_lambda = [&](int i) { /* ... */ };
std::for_each(vi1.begin(), vi1.end(),
    Foo(my_lambda));                 // 假设Foo是模板类,推导出 Foo<LambdaType>
                                     // 其中 LambdaType 是lambda表达式的唯一类型

2.4 常量模板形参的类型 (C++20起)

当一个类模板作为另一个模板的非类型模板参数的类型,并且其实参是一个常量表达式时,可以推导该类模板的模板参数。

template<class T>
struct X {
    constexpr X(T) {}
};
 
template<X x_obj> // x_obj 是一个 X 类型的非类型模板参数
struct Y {};
 
Y<0> y; // C++20起,OK。推导出 X x_obj = X<int>(0),所以 Y 的模板实参是 X<int>(0)
        // 实例化为 Y<X<int>(0)> y;

3. 类模板实参推导的机制

当编译器遇到一个只指定了类模板名 C(没有模板实参列表)的场景时,它会构建一组**虚设的函数模板(fictitious function templates)**作为推导的候选集。这个过程分为几个步骤:

3.1 隐式生成的推导指引 (Implicitly-Generated Deduction Guides)

对于类模板 C,编译器会根据其定义自动生成一些推导指引。

3.1.1 基于构造函数的指引

如果类模板 C 已经定义,并且声明了构造函数(或构造函数模板)Ci,那么对于每一个 Ci,都会生成一个对应的虚设函数模板 Fi,其特征如下:

  • 模板参数Fi 的模板参数列表是 C 的模板参数列表,其后跟随(如果 Ci 是构造函数模板)Ci 的模板参数列表。默认模板实参也会被包含。
  • 关联约束 (C++20起)Fi 的约束是 C 的约束和 Ci 的约束的逻辑与(AND)。
  • 函数参数Fi 的函数参数列表与 Ci 的参数列表相同。
  • 返回类型Fi 的返回类型是 C 的模板名后跟由 <> 包围的 C 的模板参数(例如,C<T, U>)。

特殊情况

  • 如果 C 未定义或未声明任何构造函数,会添加一个从假想的默认构造函数 C() 生成的虚设函数模板。
  • 在任何情况下,都会添加一个从假想的拷贝构造函数 C(C) 生成的虚设函数模板,称为复制推导候选 (copy deduction candidate)

3.1.2 聚合推导候选 (Aggregate Deduction Candidate, C++20起)

如果类模板 C 满足聚合类型的要求(假设其任何待决基类没有虚函数或虚基类),并且没有用户定义的推导指引,且初始化是通过非空的初始化列表(如 C c{arg1, arg2, ..., argN};,可使用指派初始化式)进行的,那么编译器可能会添加一个聚合推导候选。

这个候选的形参列表根据聚合体元素的类型和初始化器 argi 的形式来确定:

  • 若元素 ei 是数组且 argi 是花括号列表,则对应形参 Ti 是到 ei 声明类型的右值引用。
  • 若元素 ei 是数组且 argi 是字符串字面量,则对应形参 Ti 是到 const 限定的 ei 声明类型的左值引用。
  • 否则,Tiei 的声明类型。
  • 包展开的处理有特殊规则,通常尾部包展开匹配剩余初始化器。

聚合推导候选是从假想的构造函数 C(T1, T2, ..., Tn) 生成的虚设函数模板。

示例:

// 隐式生成的指引示例 (基于构造函数)
template<class T>
struct UniquePtr {
    UniquePtr(T* t);
};
 
UniquePtr dp{new auto(2.0)}; // 实际初始化表达式
 
// 编译器生成的虚设函数模板 (推导指引候选):
// F1: template<class T> UniquePtr<T> F(T* p); // 来自 UniquePtr(T*)
// F2: template<class T> UniquePtr<T> F(UniquePtr<T>); // 复制推导候选
 
// 编译器会像这样进行重载决议:
// struct X { // 假想类
//     template<class T> X(T* p);         // 对应 F1
//     template<class T> X(UniquePtr<T>); // 对应 F2
// };
// X temp_obj{new auto(2.0)}; // 使用初始化器进行直接初始化
// 重载决议选择 F1 (当 T = double)。F1 的返回类型是 UniquePtr<double>。
// 最终结果:UniquePtr<double> dp{new auto(2.0)};
// C++20 聚合推导示例
template<class T>
struct AggPair {
    T first;
    int second;
};

AggPair ap = {3.14, 42}; // 推导出 AggPair<double>
// 聚合推导候选大致为: template<class T> AggPair<T> F(T, int);

3.2 用户定义的推导指引 (User-Defined Deduction Guides)

除了编译器隐式生成的指引,开发者还可以提供自己的推导指引。这对于处理复杂情况(如从迭代器范围构造容器并推导元素类型)非常有用。

语法: 用户定义的推导指引看起来像一个带有尾随返回类型的函数声明,但函数名是类模板名。

  1. 非模板指引:

    // explicit(可选) 模板名 ( 形参列表 ) -> 简单模板标识 requires子句(可选,C++20) ;
    template<class T> struct MyVec { MyVec(size_t); /*...*/ };
    MyVec(const char*) -> MyVec<std::string>; // 用户定义指引
  2. 模板指引:

    // template <模板形参列表> requires子句(可选,C++20)
    // explicit(可选) 模板名 ( 形参列表 ) -> 简单模板标识 requires子句(可选,C++20) ;
    template<class Iter>
    container(Iter b, Iter e) -> container<typename std::iterator_traits<Iter>::value_type>;

要点

  • 推导指引不是函数,没有函数体。
  • 它们不参与普通的名字查找,仅在CTAD的重载决议中使用。
  • 必须在与类模板相同的语义作用域中引入。
  • 对于成员类模板,推导指引必须具有与模板相同的访问权限。
  • (C++20起) 用户定义推导指引中的形参不能使用占位符类型(如auto),即不能是简写函数模板形式。
  • 如果推导指引源于一个 explicit 构造函数,或者用户定义的推导指引本身被声明为 explicit,那么在复制初始化的语境中(如 C c = {...};),这个指引(或其对应的虚设构造函数)将不被考虑。

3.3 重载决议

一旦收集了所有隐式和用户定义的推导指引(表现为虚设函数模板或函数),编译器就会像对普通函数重载一样,使用提供的初始化器对这些指引进行模板实参推导和重载决议。

  • 如果重载决议失败,程序非良构。
  • 否则,被选中的最佳匹配指引 Fi返回类型就决定了最终推导出的类模板特化。

重载决议的特殊规则

  1. 偏序优先:如果一个从构造函数生成的指引比一个从用户定义指引生成的更特化,则选择前者。
  2. 复制推导优先于包装:复制推导候选通常比从包装构造函数(接受单个参数的构造函数)生成的指引更特化,因此会被优先选择。
    std::tuple t1{1};  // 推导为 std::tuple<int>
    std::tuple t2{t1}; // 推导为 std::tuple<int> (使用复制推导)
                       // 而不是 std::tuple<std::tuple<int>> (包装推导)
  3. 用户定义优先于隐式:如果其他规则无法区分,由用户定义指引生成的函数模板优先于从构造函数隐式生成的。
  4. 复制推导优先于其他隐式:在隐式生成的指引中,复制推导候选优先于其他从构造函数生成的。
  5. 非模板构造函数优先于模板构造函数:从非模板构造函数生成的指引优先于从构造函数模板生成的。

3.4 别名模板的推导 (C++20起)

当使用别名模板且未提供模板实参列表时,CTAD也会发生。编译器会基于别名模板所指向的原始类模板的推导指引来生成一组新的指引,用于别名模板的推导。这个过程涉及到从原始指引的返回类型推导别名模板的参数,并进行替换和约束组合。

template<class T> class MyVector { /* ... */ };
template<class T> MyVector(T*, T*) -> MyVector<T>; // 指引 #1

template<class T> using VecAlias = MyVector<T>;

int arr[] = {1,2,3};
VecAlias va(arr, arr+3); // 推导 VecAlias<int>,进而 MyVector<int>
// 编译器会基于 MyVector 的指引 #1 为 VecAlias 生成一个类似的指引。

4. 注意事项与注解

  • 仅当无实参列表时:CTAD只在类模板名后没有 <...> 实参列表时触发。
    std::tuple t1(1, 2, 3);                // OK:推导
    std::tuple<int, int, int> t2(1, 2, 3); // OK:显式指定
    std::tuple<> t3(1, 2, 3);              // 错误:不推导,tuple<>无匹配构造
  • 聚合体的CTAD (C++20前):在C++20之前,聚合类模板的CTAD通常需要用户定义的推导指引,因为隐式生成的指引可能不覆盖所有聚合初始化的情况。C++20通过聚合推导候选改进了这一点。
  • 注入类名:在类模板的作用域内,不带模板参数的模板名是该类的注入类名,代表当前特化(如 X<T>)。此时不发生CTAD。要触发CTAD,需要使用全局作用域解析 ::X
  • 转发引用与CTAD:在隐式生成的推导指引中,如果构造函数的参数类型是 T&&,其中 T 是类模板自身的模板参数,那么这个 T&& 不是转发引用。但在用户定义的推导指引中,template<class T> C(T&&) -> C<...>; 里的 T&& 转发引用。这会影响推导出的类型(值类型还是引用类型)。

5. 功能特性测试宏

  • __cpp_deduction_guides
    • 201703L (C++17): 基本的类模板实参推导。
    • 201907L (C++20): 增加了对聚合体和别名模板的CTAD支持。

6. 总结

类模板实参推导(CTAD)是C++17引入的一项重要便利特性,它通过编译器的智能推导,显著减少了在实例化类模板时显式指定模板参数的需要,使得代码更加简洁易读。理解其工作机制,特别是隐式生成指引、用户定义指引以及重载决议规则,有助于我们更有效地利用这一特性,并编写出更优雅的现代C++代码。随着C++20对聚合体和别名模板推导的增强,CTAD的应用范围进一步扩大,成为现代C++编程中不可或缺的一部分。

C++属性详解 (C++11 至 C++23)

自C++11起,标准引入了属性(Attributes) 的概念,允许开发者向编译器提供关于代码实体的额外信息,这些信息可能用于优化、警告、代码生成或其他特定行为。属性使用双方括号 [[...]] 的形式书写。本教程将梳理从C++11到C++23标准中引入的主要属性,并提供代码示例。

1. C++11 引入的属性

1.1 [[noreturn]]

  • 用途:指示一个函数在执行后不会返回到调用者。如果标记为 [[noreturn]] 的函数确实返回了,行为是未定义的。
  • 适用对象:函数声明。
  • 示例
    #include <iostream>
    #include <stdexcept> // For std::terminate
    
    [[noreturn]] void terminate_program(const std::string& message) {
        std::cerr << "Error: " << message << std::endl;
        std::exit(EXIT_FAILURE); // 或者 std::terminate();
    }
    
    int main() {
        terminate_program("Fatal error occurred.");
        // 编译器知道这里的代码不会被执行
        std::cout << "This line will not be reached." << std::endl; 
        return 0; 
    }
    编译器可以利用此信息进行代码优化(如省略返回路径的代码)或抑制关于无返回值的警告。

1.2 [[carries_dependency]]

  • 用途:用于优化多线程环境下的内存序。它指示函数参数或返回值携带数据依赖关系到函数体或从函数体传出。这主要与 std::memory_order_consume 内存序相关,但 memory_order_consume 的支持在实践中非常有限,并且在C++17中被临时劝阻使用。
  • 适用对象:函数参数,函数声明(针对返回值)。
  • 说明:这个属性非常底层且难以正确使用,通常只在编写高度优化的并发库时才可能遇到。由于其复杂性和实现问题,在常规应用开发中几乎不使用。
  • 示例 (概念性,实际应用复杂):
    // (此示例仅为演示语法,实际应用需谨慎)
    #include <atomic>
    #include <thread>
    
    std::atomic<int*> g_ptr;
    int g_data;
    
    void producer() {
        int* p = new int(42);
        g_data = 100;
        g_ptr.store(p, std::memory_order_release);
    }
    
    // consume_data 期望通过 p 的依赖关系来看到 g_data 的正确值
    // [[carries_dependency]] 告知编译器不要破坏这种依赖
    void consume_data([[carries_dependency]] int* p) {
        if (p) {
            // 如果没有[[carries_dependency]]且编译器优化激进,
            // 对g_data的读取可能先于p的依赖,导致读到旧值。
            // 使用std::memory_order_consume和[[carries_dependency]]旨在避免这种情况。
            // 但memory_order_consume本身支持度不高。
            // (实际代码会更复杂,这里只是示意)
            // int local_data = g_data; 
        }
    }
    
    void consumer() {
        int* p;
        while (!(p = g_ptr.load(std::memory_order_consume))) {
            std::this_thread::yield();
        }
        consume_data(p);
        // ...
    }
    注意:由于 std::memory_order_consume 的复杂性和实现挑战,C++标准委员会正在重新审视其规范。目前,更推荐使用 std::memory_order_acquire

2. C++14 引入的属性

2.1 [[deprecated]][[deprecated("reason")]]

  • 用途:指示某个实体(类、函数、变量、枚举等)已被弃用,不推荐继续使用。编译器在遇到被弃用实体的引用时,通常会发出警告。可以提供一个可选的字符串字面量作为弃用的原因。
  • 适用对象:类、typedef、变量、非静态数据成员、函数、枚举、模板特化。
  • 示例
    #include <iostream>
    
    [[deprecated("Use NewFunction() instead.")]]
    void OldFunction(int x) {
        std::cout << "OldFunction called with: " << x << std::endl;
    }
    
    void NewFunction(int x) {
        std::cout << "NewFunction called with: " << x << std::endl;
    }
    
    class [[deprecated("This class is outdated.")]] OldClass {
    public:
        int value;
    };
    
    int main() {
        OldFunction(10); // 编译器通常会在此处发出警告
        OldClass oc;     // 编译器警告
        NewFunction(20);
        return 0;
    }

3. C++17 引入的属性

3.1 [[fallthrough]]

  • 用途:用于 switch 语句中,明确指示某个 case 标签后的代码执行会故意“贯穿”到下一个 case 标签,以抑制编译器可能因此发出的警告。
  • 适用对象switch 语句中的空语句(通常在 case 标签和下一个 casedefault 之间)。
  • 示例
    #include <iostream>
    
    void process_case(int val) {
        switch (val) {
            case 1:
                std::cout << "Case 1 executed." << std::endl;
                // 故意贯穿
                [[fallthrough]]; 
            case 2:
                std::cout << "Case 2 executed (possibly due to fallthrough from 1)." << std::endl;
                break;
            case 3:
                std::cout << "Case 3 executed." << std::endl;
                // 没有 break 或 [[fallthrough]],编译器可能会警告
            case 4:
                std::cout << "Case 4 executed." << std::endl;
                break;
            default:
                std::cout << "Default case." << std::endl;
        }
    }
    
    int main() {
        process_case(1);
        std::cout << "---" << std::endl;
        process_case(3);
        return 0;
    }

3.2 [[nodiscard]][[nodiscard("reason")]]

  • 用途:用于函数或类(当用于类时,适用于其返回该类类型对象的函数,包括构造函数)。它鼓励调用者使用函数的返回值。如果调用者忽略了标记为 [[nodiscard]] 的函数的返回值,编译器通常会发出警告。可以提供一个可选的字符串字面量作为原因。
  • 适用对象:函数声明,类/枚举声明。
  • 示例
    #include <vector>
    #include <iostream>
    
    [[nodiscard]] 
    bool check_status() {
        return true; // 假设这个状态很重要
    }
    
    [[nodiscard("Ignoring the error code can lead to issues.")]]
    int get_error_code() {
        return 0; 
    }
    
    struct [[nodiscard]] ImportantHandle {
        ImportantHandle(int id) : id_(id) {}
        ~ImportantHandle() { std::cout << "Handle " << id_ << " released." << std::endl; }
        int id_;
    };
    
    ImportantHandle create_handle(int id) {
        return ImportantHandle(id); // 构造函数返回类型是ImportantHandle,所以nodiscard生效
    }
    
    int main() {
        check_status(); // 编译器通常会警告:忽略了[[nodiscard]]函数的返回值
    
        int error = get_error_code(); // OK,返回值被使用
        get_error_code();             // 编译器警告,并可能显示原因
    
        create_handle(1); // 编译器警告:忽略了nodiscard类型的返回值
        ImportantHandle h = create_handle(2); // OK
    
        return 0;
    }

3.3 [[maybe_unused]]

  • 用途:指示一个实体(如变量、函数参数、类型别名、类、枚举等)可能未被使用,以抑制编译器因此发出的未使用警告。
  • 适用对象:类声明、typedef声明、变量声明、非静态数据成员、函数声明、枚举声明、枚举项。
  • 示例
    #include <iostream>
    
    void process_data(int data, [[maybe_unused]] bool debug_flag) {
        // 在非调试构建中,debug_flag 可能不被使用
        #ifdef ENABLE_DEBUG
        if (debug_flag) {
            std::cout << "Debug: Processing " << data << std::endl;
        }
        #endif
        // ... 正常处理 data ...
    }
    
    int main() {
        [[maybe_unused]] int unused_variable = 10; // 如果此变量后续未被使用,不会产生警告
        process_data(100, true);
        return 0;
    }

4. C++20 引入的属性

4.1 [[likely]][[unlikely]]

  • 用途:向编译器提供分支预测的提示。[[likely]] 用于标记 ifswitch 语句的某个分支(通常是 if (condition) [[likely]] { ... }case label [[likely]]:)更有可能被执行,而 [[unlikely]] 则标记不太可能被执行的分支。编译器可以利用这些信息优化代码布局,以改善指令缓存和分支预测的性能。
  • 适用对象:语句(特别是 ifswitch 语句的条件部分,或者 case 标签)。
  • 示例
    #include <iostream>
    
    void process_input(int input) {
        if (input > 0) [[likely]] { 
            // 假设正数输入是常见情况
            std::cout << "Processing positive input." << std::endl;
        } else if (input == 0) {
            std::cout << "Processing zero input." << std::endl;
        } else [[unlikely]] {
            // 假设负数输入是罕见情况
            std::cout << "Processing negative input (error?)." << std::endl;
        }
    }
    
    int main() {
        process_input(10);
        process_input(-5);
        return 0;
    }
    注意:这些属性是给编译器的提示,编译器可以选择忽略它们。过度或错误地使用可能不会带来性能提升,甚至可能产生负面影响。

4.2 [[no_unique_address]]

  • 用途:用于非静态数据成员。如果该成员是空类类型(EBO - Empty Base Optimization 的候选者)或者其类型允许,编译器可以优化类对象的布局,使得该成员不占用额外的空间(如果它是空的)。这对于编写零开销抽象非常有用。
  • 适用对象:非静态数据成员。
  • 示例
    #include <iostream>
    
    struct Empty {}; // 空类
    
    struct MyAllocator { // 另一个空类,可能用于元编程
        void* allocate(size_t s) { return malloc(s); }
        void deallocate(void* p) { free(p); }
    };
    
    template<typename T, typename Allocator = MyAllocator>
    class MyVector {
        T* data_;
        size_t size_;
        [[no_unique_address]] Allocator alloc_; // 如果Allocator是空类,alloc_可能不占空间
    
    public:
        MyVector() : data_(nullptr), size_(0), alloc_() {}
        // ... 其他成员 ...
    };
    
    int main() {
        MyVector<int> vec_default_alloc;
        MyVector<int, Empty> vec_empty_alloc; // Empty是空类
    
        // sizeof(vec_empty_alloc) 理论上可能等于 sizeof(int*) + sizeof(size_t)
        // 而不是 sizeof(int*) + sizeof(size_t) + sizeof(Empty) (如果Empty占1字节)
        std::cout << "sizeof(MyVector<int>): " << sizeof(vec_default_alloc) << std::endl;
        std::cout << "sizeof(MyVector<int, Empty>): " << sizeof(vec_empty_alloc) << std::endl;
        
        return 0;
    }
    实际的布局优化取决于编译器。

5. C++23 引入的属性

C++23 标准继续扩展属性列表,但截至我知识更新时(2023年初),一些提案仍在最终确定阶段或刚被接纳,编译器支持可能尚不普遍。以下是一些被讨论或已采纳的:

5.1 [[assume(expression)]] (P1774R8)

  • 用途: 向编译器提供一个假设,即 expression 在该点始终为真。编译器可以使用这个信息进行更激进的优化,例如删除被认为不可能执行到的代码路径。如果运行时 expression 实际为假,则行为是未定义的。
  • 适用对象: 语句。
  • 示例:
    void process_array(int* arr, size_t size) {
        if (size > 0) {
             [[assume(arr != nullptr)]]; // 假设如果size > 0,则arr一定不是nullptr
             for (size_t i = 0; i < size; ++i) {
                 arr[i] *= 2; // 编译器可能基于arr != nullptr优化这里的解引用
             }
        }
    }
    注意: 滥用 [[assume]] 可能导致难以调试的错误,因为它允许编译器基于可能不成立的假设进行优化。

其他讨论中的或特定用途的属性

C++标准是一个持续演进的过程,新的属性提案会不断出现。例如,可能会有更多与契约式编程(Contracts)、反射、并发或特定硬件优化相关的属性。建议查阅最新的C++标准文档和编译器文档以获取最准确和最全面的信息。

6. 属性的通用规则

  • 命名空间:标准属性不位于任何命名空间下(如 std::)。但允许实现定义带有命名空间的属性(例如 [[gnu::unused]])。
  • 忽略未知属性:编译器必须忽略它不认识的属性(包括拼写错误的标准属性),通常会伴随一个警告。
  • 位置:属性可以应用于多种声明和语句,具体位置取决于属性的定义。

7. 总结

C++属性提供了一种标准化的方式来向编译器传递元信息,以期改善代码质量、性能或开发体验。从C++11到C++23,属性的集合不断丰富,涵盖了从函数行为、弃用标记、分支预测到内存布局优化等多个方面。开发者应了解并适当使用这些属性,但也要注意它们并非万能药,特别是像 [[likely]]/[[unlikely]][[assume]] 这样的优化提示,需要谨慎评估其效果。始终参考最新的C++标准和编译器文档是获取最准确信息的最佳途径。

C++奇异递归模板模式(CRTP)深度剖析:原理、应用与常见陷阱

1. 简介

奇异递归模板模式(Curiously Recurring Template Pattern, CRTP)是C++中一种著名且独特的惯用法。其核心思想在于基类是一个模板类,而这个模板类的模板参数恰好是继承自它的派生类本身

让我们通过一个简单的代码示例来直观感受CRTP的结构:

// 基类模板
template <typename DerivedType>
class Base {
public:
    void CommonOperation() {
        // 将this指针安全地转换为派生类类型的引用或指针
        DerivedType& derived = static_cast<DerivedType&>(*this);
        // 或者使用指针:
        // DerivedType* derived_ptr = static_cast<DerivedType*>(this);

        // 调用派生类特有的方法
        derived.SpecificImplementation();
    }
};

// 派生类,将自身作为模板参数传递给基类
class Derived : public Base<Derived> {
public:
    void SpecificImplementation() {
        // ... 派生类特有的实现 ...
        // std::cout << "Derived::SpecificImplementation called" << std::endl;
    }
};

CRTP模式的关键特征

  1. 基类是模板类:基类 Base 接受一个模板参数 DerivedType
  2. 派生类将自身作为模板参数:派生类 Derived 在继承 Base 时,将 Derived 自身作为模板参数 DerivedType 传递给 Base,即 class Derived : public Base<Derived>
  3. 基类中使用派生类:在基类 Base 的成员函数(如 CommonOperation)中,通过 static_castthis 指针(或引用)转换为 DerivedType* (或 DerivedType&),从而能够调用派生类 DerivedType 中定义的成员函数(如 SpecificImplementation)。

CRTP的目的

CRTP的主要目的是允许基类在编译期访问和使用其派生类的成员(通常是方法或类型别名),而无需依赖虚函数和动态多态。这使得基类可以提供通用的框架或行为,而将具体的实现细节委托给派生类。

2. CRTP的典型应用场景与优势

CRTP的本质——“基类定义接口/框架,派生类提供具体实现”——使其在多种场景下都非常有用,主要优势在于静态多态带来的性能提升和代码复用的便利性。

2.1 实现静态接口(静态多态)

传统的面向对象设计通过虚函数实现多态,这涉及到运行时的虚函数表查找(vtbl dispatch),会带来一定的性能开销。CRTP提供了一种实现多态行为的方式,但绑定在编译期完成,因此称为静态多态

在CRTP中,基类的方法可以看作是“接口”定义,而派生类通过提供特定名称和签名的方法来实现这些“接口”。

示例:通用的 Writer 接口

template<typename ConcreteWriter>
class Writer {
public:
    bool Write(int pos, void* data, int size) {
        // 将this转换为具体的Writer类型,并调用其实现方法
        ConcreteWriter* concrete_writer = static_cast<ConcreteWriter*>(this);
        return concrete_writer->WriteImpl(pos, data, size);
    }
};

class FileWriter : public Writer<FileWriter> {
public:
    FileWriter(const std::string& filename) { /* ... 打开文件 ... */ }
private:
    // 将WriteImpl声明为私有,并通过友元暴露给基类
    // 这样可以防止外部直接调用WriteImpl,保持接口的一致性
    friend class Writer<FileWriter>; 
    bool WriteImpl(int pos, void* data, int size) {
        // ... 实际写入文件的逻辑 ...
        // return true; 
    }
};

class SocketWriter : public Writer<SocketWriter> {
public:
    SocketWriter(const std::string& ip, int port) { /* ... 连接socket ... */ }
private:
    friend class Writer<SocketWriter>;
    bool WriteImpl(int pos, void* data, int size) {
        // ... 实际写入socket的逻辑 ...
        // return true;
    }
};

在这个例子中,Writer::Write 方法定义了一个稳定的接口,具体的写操作由派生类的 WriteImpl 实现。由于 static_cast 和模板的特性,调用在编译期就已经解析完成,没有虚函数开销。

与动态多态的对比: CRTP实现的静态多态效率更高,但它不能像传统的动态多态那样轻易地将不同派生类对象存储在同一个基类指针容器中(例如 std::vector<Writer*>). 因为 Writer<FileWriter>Writer<SocketWriter> 是完全不同的类型。虽然可以通过引入共同的非模板抽象基类或使用 std::any 等技术来模拟异构容器,但这些方案通常不如动态多态直接。选择哪种多态方式取决于具体需求。

2.2 扩展功能(代码注入)

CRTP允许在基类中添加新的通用功能,这些功能可以利用派生类提供的基础操作。当基类增加新功能时,所有派生类自动“获得”这些新功能,而无需修改派生类代码。

示例:数值算法扩展

假设我们有一些基础算术操作类,希望在其上构建更高级的算法。

// 基类模板,提供高阶算法框架
template<typename ConcreteAlgorithm, typename NumericType>
class HighLevelAlgo {
public:
    // 高阶功能:求和 (示例用变参模板,实际可能不同)
    template<typename... Args>
    NumericType Sum(NumericType first, Args... rest) {
        ConcreteAlgorithm* concrete_algo = static_cast<ConcreteAlgorithm*>(this);
        NumericType current_sum = first;
        // 伪代码,实际需要递归或折叠表达式
        // ((current_sum = concrete_algo->Add(current_sum, rest)), ...); 
        // 简化版:假设派生类有Add(NumericType, NumericType)
        NumericType temp_args[] = {rest...};
        for(const auto& arg : temp_args) {
            current_sum = concrete_algo->Add(current_sum, arg);
        }
        return current_sum;
    }

    // 高阶功能:平方
    NumericType Square(const NumericType& val) {
        ConcreteAlgorithm* concrete_algo = static_cast<ConcreteAlgorithm*>(this);
        return concrete_algo->Multiply(val, val); // 依赖派生类的Multiply
    }
};

// 具体算法实现:整数运算
class IntAlgo : public HighLevelAlgo<IntAlgo, int> {
public:
    int Add(int a, int b) { return a + b; }
    int Multiply(int a, int b) { return a * b; }
    // ... 其他基础运算:Subtract, Divide ...
};

// 具体算法实现:复数运算 (假设Complex类已定义)
class ComplexAlgo : public HighLevelAlgo<ComplexAlgo, Complex> {
public:
    Complex Add(const Complex& a, const Complex& b) { return a + b; }
    Complex Multiply(const Complex& a, const Complex& b) { return a * b; }
    // ...
};

如果需要在 HighLevelAlgo 中增加一个新的高阶运算(如求模 Modulo),只要这个运算能基于派生类已提供的基础操作(如加、减、乘、除)实现,那么所有派生类(IntAlgo, ComplexAlgo等)将自动拥有 Modulo 功能,无需任何修改。

2.3 静态模板方法模式

设计模式中的“模板方法模式”定义了一个操作中的算法骨架,而将一些步骤延迟到子类中。子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。CRTP可以用于实现此模式的静态版本。

template<typename ConcreteWorker>
class WorkerFramework {
public:
    void DoProcess() {
        ConcreteWorker* concrete_worker = static_cast<ConcreteWorker*>(this);
        concrete_worker->Step1_Initialize();
        concrete_worker->Step2_ExecuteCoreLogic();
        concrete_worker->Step3_Finalize();
    }
};

class StrongWorker : public WorkerFramework<StrongWorker> {
public:
    void Step1_Initialize() { /* ... StrongWorker的初始化 ... */ }
    void Step2_ExecuteCoreLogic() { /* ... StrongWorker的核心逻辑 ... */ }
    void Step3_Finalize() { /* ... StrongWorker的清理 ... */ }
};

class SmartWorker : public WorkerFramework<SmartWorker> {
public:
    void Step1_Initialize() { /* ... SmartWorker的初始化 ... */ }
    void Step2_ExecuteCoreLogic() { /* ... SmartWorker的核心逻辑 ... */ }
    void Step3_Finalize() { /* ... SmartWorker的清理 ... */ }
};

WorkerFramework::DoProcess 定义了处理流程的骨架,而具体的步骤由 StrongWorkerSmartWorker 实现。同样,这里没有虚函数调用。

2.4 多态的复制构造 (Polymorphic Copy Construction)

在传统的基于虚函数的继承体系中,复制一个通过基类指针指向的对象(即创建其实际派生类型的副本)是一个常见问题。通常的解决方案是在基类中声明一个纯虚的 clone() 方法,并由每个派生类重写。

class Shape {
public:
    virtual ~Shape() = default;
    virtual std::unique_ptr<Shape> clone() const = 0;
};

class Triangle : public Shape {
public:
    std::unique_ptr<Shape> clone() const override {
        return std::make_unique<Triangle>(*this); // 调用Triangle的拷贝构造
    }
};

这种方法要求每个派生类都重复实现类似的 clone() 逻辑。CRTP可以帮助消除这种重复代码。

// 抽象基类 (可选,用于异构容器)
class AbstractShape {
public:
    virtual ~AbstractShape() = default;
    virtual std::unique_ptr<AbstractShape> clone() const = 0;
};

// CRTP基类,提供通用的clone实现
template <typename DerivedShapeType>
class ClonableShape : public AbstractShape { // 可选继承AbstractShape
public:
    std::unique_ptr<AbstractShape> clone() const override {
        // static_cast到派生类类型,然后调用派生类的拷贝构造函数
        return std::make_unique<DerivedShapeType>(static_cast<const DerivedShapeType&>(*this));
    }

protected:
    // 防止直接实例化ClonableShape,并允许派生类构造
    ClonableShape() = default;
    ClonableShape(const ClonableShape&) = default;
    ClonableShape(ClonableShape&&) = default; // C++11起通常也应提供移动
    ClonableShape& operator=(const ClonableShape&) = default;
    ClonableShape& operator=(ClonableShape&&) = default;
};

// 具体形状类,只需继承并传入自身类型
class Triangle : public ClonableShape<Triangle> { /* ... Triangle特有成员 ... */ };
class Rectangle : public ClonableShape<Rectangle> { /* ... */ };
class Circle : public ClonableShape<Circle> { /* ... */ };

通过这种方式,clone() 的实现被集中在 ClonableShape 基类中,派生类只需继承即可获得多态复制能力。protected 构造函数可以防止用户意外地创建 ClonableShape<SomeUnrelatedType> 实例。

如果不需要将不同形状存储在 AbstractShape* 的容器中,AbstractShape 基类可以省略,ClonableShape::clone() 的返回类型可以直接是 std::unique_ptr<DerivedShapeType>

2.5 独立的对象计数器

静态成员变量常用于实现对象计数,但传统的单一计数器类无法为不同类型的对象分别计数。CRTP可以为每种类型生成一个独立的计数器。

template<typename CountedType>
class ObjectCounter {
public:
    ObjectCounter() { ++count; }
    ObjectCounter(const ObjectCounter&) { ++count; } // 拷贝也计数
    ~ObjectCounter() { --count; }

    static size_t HowMany() { return count; }

private:
    static size_t count; // 每个CountedType特化都有自己的count
};

// 静态成员定义
template<typename CountedType>
size_t ObjectCounter<CountedType>::count = 0;

// 使用CRTP进行计数
class Widget : private ObjectCounter<Widget> { // 私有继承,避免不当使用
public:
    using ObjectCounter<Widget>::HowMany; // 将计数方法暴露出来
    // ... Widget的其他成员 ...
};

class Gadget : private ObjectCounter<Gadget> {
public:
    using ObjectCounter<Gadget>::HowMany;
    // ...
};

int main() {
    Widget w1, w2;
    Gadget g1;

    std::cout << "Widgets: " << Widget::HowMany() << std::endl;   // 输出 2
    std::cout << "Gadgets: " << Gadget::HowMany() << std::endl; // 输出 1
    return 0;
}

使用私有继承是为了防止通过基类指针 ObjectCounter<Widget>* 意外地 delete 一个 Widget 对象(因为 ObjectCounter 的析构函数非虚),这会导致未定义行为。私有继承切断了 "is-a" 关系,使得这种指针转换非法。如果采用组合方式,C++20的 [[no_unique_address]] 属性可以帮助实现空基类优化(EBO)的效果,即使 ObjectCounter 作为成员变量。

3. CRTP的典型错误与规避

3.1 使用错误的模板参数

CRTP的核心在于派生类将自身作为模板参数传递给基类。如果传递了错误的类型,可能会导致编译错误或难以察觉的运行时逻辑错误。

template <typename T> class Base { /* ... */ };
class Derived1 : public Base<Derived1> { /* 正确 */ };
// class Derived2 : public Base<Derived1> { /* 错误!Derived2的行为会像Derived1 */ }; 

规避方法:将基类的构造函数声明为 privateprotected,然后将模板参数 T(即派生类)声明为基类的 friend

template <typename T>
class Base {
public:
    // ... 公共接口 ...
protected: // 或 private
    Base() = default; // 允许派生类调用
    friend T;         // 允许T(即正确的派生类)访问构造函数
};

class DerivedCorrect : public Base<DerivedCorrect> {}; // OK

// class DerivedWrong : public Base<DerivedCorrect> {}; 
// 编译错误: DerivedWrong 不是 Base<DerivedCorrect> 的友元,无法访问其构造函数

3.2 派生类方法覆盖(隐藏)基类方法

如果派生类中定义了一个与基类中非虚方法同名的方法,即使参数列表不同,派生类的方法也会隐藏(或称覆盖,尽管术语上更精确的是隐藏,因为基类方法非虚)基类的同名方法。这可能导致在CRTP基类中通过 static_cast 调用派生类方法时,实际调用的不是预期的版本。

template<typename T>
struct BaseWithFunc {
    void Process() {
        T* sub = static_cast<T*>(this);
        // 期望调用 BaseWithFunc::Func(int, double) 或 T::Func(int, double)
        sub->Func(6, 6.8); 
    }
    void Func(int a, double b) { std::cout << "Base::Func(int, double)" << std::endl; }
};

struct DerivedWithHiding : public BaseWithFunc<DerivedWithHiding> {
    // 这个Func隐藏了BaseWithFunc::Func(int, double)
    void Func(int a, int b) { std::cout << "Derived::Func(int, int)" << std::endl; }
};

int main() {
    DerivedWithHiding d;
    d.Process(); // 调用会尝试匹配 DerivedWithHiding::Func(int, int)
                 // 如果参数不匹配,会导致编译错误,或者如果存在隐式转换则可能调用不期望的版本
}

规避方法

  1. 确保派生类中用于实现CRTP委托的方法与基类期望的签名完全一致。
  2. 如果确实需要在派生类中定义同名但参数不同的方法,并且仍希望访问基类的版本,可以在派生类中使用 using Base<DerivedType>::FuncName; 来将基类方法引入派生类的作用域,从而参与重载决议。

4. 总结

CRTP是一种强大而灵活的C++技术,它通过一种“奇异的”递归模板继承结构,实现了编译期的静态多态和代码复用。它避免了虚函数的运行时开销,允许基类访问派生类的具体实现,并且是非侵入式的。然而,正确使用CRTP需要对模板和继承机制有深入的理解,并注意其潜在的陷阱,如模板参数的正确传递和方法隐藏问题。在合适的场景下,CRTP是实现高性能、高复用性代码的有效工具。

宏定义的现代 C++ 替代方案对照表

在现代 C++(C++11 及以上)中,应尽量避免使用 #define 宏来定义常量、函数或逻辑片段,因为宏是预处理器层面的文本替换,不受类型系统、作用域规则约束,容易引发错误。以下是推荐的替代方案及理由。


宏与现代 C++ 替代对照

旧式宏用法 推荐替代 原因与说明
#define PI 3.14159 constexpr double PI = 3.14159; 类型安全、作用域明确、可调试、支持模板
#define SIZE 1024 inline constexpr std::size_t SIZE = 1024; 防止重复定义冲突(ODR),支持头文件中使用
#define MAX(a,b) ((a)>(b)?(a):(b)) template<typename T> constexpr T max(T a, T b) 避免副作用,支持类型推导,避免重复求值
#define SQUARE(x) ((x)*(x)) constexpr int square(int x) 表达清晰,支持 constexpr 计算,避免副作用
#define VERSION "1.2.3" constexpr const char* VERSION = "1.2.3"; 类型安全,易读,支持模板参数
#define LOG(x) std::cout << x << std::endl inline void log(auto x) 类型推导安全,调试友好,可被 IDE 补全
#define FLAG_A 1
#define FLAG_B 2
enum class Flag { A = 1, B = 2 } 命名空间封装,避免值冲突,强类型控制
#ifndef HEADER_H
#define HEADER_H
#pragma once 编译器支持更好,减少宏污染,写法更简洁

不能用现代语法替代的场景(仍需使用宏)

场景 示例 原因
编译开关控制 #ifdef DEBUG / #if PLATFORM == WINDOWS constexpr 发生在编译阶段之后,无法控制是否参与编译
平台相关代码分支 #ifdef _WIN32 C++ 语言本身无法识别平台宏,必须依赖预处理器
条件包含头文件 #ifdef USE_MYLIB\n #include "mylib.h" 语言层级无法控制是否包含头文件
C++ 版本特性适配 #if __cplusplus >= 202002L 识别语言版本必须用宏
兼容 C / 外部编译器行为 #define EXPORT __declspec(dllexport) 外部接口约定通常基于宏

替代宏的现代技术概览

技术 用途 替代宏的能力
constexpr / const 常量定义 值替换
inline 函数替换 替代函数宏
模板函数 / 泛型函数 类型敏感的计算 避免类型错误
enum class 标志常量 类型安全,作用域封装
if constexpr 模板条件选择 替代部分条件逻辑(非编译开关)
#pragma once 防止重复包含 替代 include guard 宏(非标准但主流支持)

建议实践规范

  • 优先使用 constexpr / inline constexpr 定义常量
  • 使用模板和函数代替函数宏
  • 使用 enum class 替代裸整数标志
  • 使用 #pragma once 替代传统 include guard
  • 仅在控制编译路径、平台相关性时使用 #define#ifdef
  • 避免复杂宏逻辑(如参数宏嵌套、拼接宏等)

示例项目参考

// config.hpp
#pragma once

inline constexpr int BUFFER_SIZE = 1024;

inline void log(auto&& msg) {
    std::cout << "[LOG] " << msg << std::endl;
}

enum class Mode : int {
    Read  = 1,
    Write = 2,
    Exec  = 4,
};

参考资料

@KuRRe8
Copy link
Author

KuRRe8 commented May 10, 2025

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment