补充阶段:会员系统与类型擦除(Type Erasure)

超市想搞会员积分,会员有不同等级(普通、黄金、钻石)。我们需要存储不同类型的会员信息,但不想用继承(因为会员类型固定,且可能增加新类型)。这就可以用 std::variant类型擦除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Member {
struct Concept {
virtual ~Concept() = default;
virtual int getPoints() const = 0;
virtual std::unique_ptr<Concept> clone() const = 0;
};
template<typename T>
struct Model : Concept {
T data;
Model(T d) : data(std::move(d)) {}
int getPoints() const override { return data.getPoints(); }
std::unique_ptr<Concept> clone() const override {
return std::make_unique<Model<T>>(data);
}
};
std::unique_ptr<Concept> pimpl;
public:
template<typename T>
Member(T t) : pimpl(std::make_unique<Model<T>>(std::move(t))) {}
Member(const Member& other) : pimpl(other.pimpl->clone()) {}
int getPoints() const { return pimpl->getPoints(); }
};

这就是类型擦除,它用模板和虚函数隐藏了具体类型。它比继承更灵活,但代价是多一层间接和动态分配。这里要理解 std::functionstd::any 都是类似原理。而且要注意拷贝语义——我们实现了深拷贝(clone),否则默认拷贝会共享资源。

会员等级判断:RTTI 与 dynamic_cast

如果我们用继承实现会员类型,有时需要根据实际类型做特殊处理,比如钻石会员额外返现。这时可以用 dynamic_cast

1
2
3
if (DiamondMember* dm = dynamic_cast<DiamondMember*>(member)) {
// 额外返现
}

dynamic_cast 依赖 RTTI(运行时类型信息),它通过虚表指针找到对象的类型信息,然后做类型比较。但 RTTI 有开销,且可能被编译器关闭(-fno-rtti)。在性能敏感代码中,可以用枚举或虚函数替代。这里要深入:RTTI 的实现通常是在虚表里放一个指向 type_info 的指针,dynamic_cast 需要遍历继承树,所以多继承下更耗时。

商品推荐系统:Lambda 表达式与算法

根据顾客购买历史推荐商品,需要复杂的过滤和排序。Lambda 让代码更简洁:

1
2
3
4
5
6
std::vector<Product*> candidates;
// 填充候选商品
std::sort(candidates.begin(), candidates.end(),
[&](Product* a, Product* b) {
return customer.preferenceScore(a) > customer.preferenceScore(b);
});

Lambda 的本质是匿名函数对象,编译器会生成一个重载了 operator() 的类。捕获列表对应成员变量,按值捕获会拷贝,按引用捕获要小心生命周期。C++14 支持泛型 lambda,相当于模板。C++20 允许模板 lambda,更灵活。这里要讲清楚捕获的原理、mutable 关键字的作用、以及 lambda 到函数指针的转换(仅当无捕获时)。

多种促销组合:变参模板与折叠表达式

超市可能同时有多种促销:满减、折扣、会员价。我们需要一个函数能接受任意数量的促销规则,并计算出最终价格。这可以用变参模板折叠表达式(C++17):

1
2
3
4
template<typename... Rules>
double calculatePrice(double basePrice, Rules... rules) {
return (basePrice * ... * rules.discountFactor()); // 假设每个规则返回折扣系数
}

这里的折叠表达式 ( ... * rules.discountFactor() ) 会展开成 rule1.discountFactor() * rule2.discountFactor() * ...。如果规则可能返回 std::optional<double> 表示不适用,就需要更复杂的处理。变参模板的展开机制是递归实例化,模板元编程的基础。还要理解 sizeof...(Rules) 获取参数个数。

库存动态调整:SFINAE 与 Concepts

有时我们想根据类型是否支持某个操作来决定实现,比如某些商品支持“热卖”标记,某些不支持。可以用 SFINAE(替换失败不是错误)来检测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T, typename = void>
struct has_hot_flag : std::false_type {};

template<typename T>
struct has_hot_flag<T, std::void_t<decltype(std::declval<T>().hotFlag)>>
: std::true_type {};

template<typename T>
void applyHotFlag(T& product) {
if constexpr (has_hot_flag<T>::value) {
product.hotFlag = true;
} else {
// 不支持,忽略
}
}

std::void_t 是 C++17 的工具,它利用 SFINAE:如果 ThotFlag 成员,则 decltype 合法,void_t 实例化为 void,匹配偏特化。否则替换失败,走主模板。C++20 有了 Concepts,可以更直观:

1
2
3
4
5
6
7
template<typename T>
concept HasHotFlag = requires(T t) { t.hotFlag; };

template<HasHotFlag T>
void applyHotFlag(T& product) { product.hotFlag = true; }

void applyHotFlag(auto&) {} // 重载,非约束版本

Concepts 不仅简化了写法,还提供了更友好的错误信息,并且可以在编译期做类型约束,比如限制模板参数必须支持某种操作。这是 C++20 的重要改进。

日志系统的线程安全:内存序与原子操作

前面提到日志单例的线程安全,但如果多个线程并发写日志,我们可能用无锁队列来避免锁竞争。实现一个无锁队列需要理解原子操作的内存序。比如一个简单的 SPSC 队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<typename T>
class SPSCQueue {
std::atomic<size_t> head{0}, tail{0};
T* buffer;
size_t size;
public:
bool push(T value) {
size_t t = tail.load(std::memory_order_relaxed);
size_t n = (t + 1) % size;
if (n == head.load(std::memory_order_acquire)) return false; // full
buffer[t] = value;
tail.store(n, std::memory_order_release);
return true;
}
bool pop(T& value) {
size_t h = head.load(std::memory_order_relaxed);
if (h == tail.load(std::memory_order_acquire)) return false; // empty
value = buffer[h];
head.store((h + 1) % size, std::memory_order_release);
return true;
}
};

这里的内存序是关键:relaxed 用于内部计数,acquire 确保看到对方的最新写入,release 确保之前的内存操作对其他线程可见。这些内存序对应 CPU 的内存屏障指令,错误使用会导致难以调试的并发 bug。C++ 内存模型定义了六种内存序,需要根据场景选择。

高性能计算:SIMD 与内联汇编

如果超市需要实时分析销售数据(比如统计每分钟销售额),可以用 SIMD 加速。C++ 标准库没有直接支持,但编译器提供了 intrinsics,或者用 std::experimental::simd。更底层可以用内联汇编:

1
2
3
4
5
6
7
8
9
10
11
double sum(const std::vector<double>& v) {
double res = 0;
asm volatile(
"movq $-64, %%rax\n"
"1: addpd (%%rcx,%%rax), %%xmm0\n"
"addq $16, %%rax\n"
"jnz 1b\n"
: : "c"(v.data()) : "xmm0", "rax", "cc"
);
// 还需要处理剩余元素...
}

但内联汇编可移植性差,且容易出错。现代 C++ 更推荐用编译器 intrinsics,比如 Intel 的 _mm_add_pd。这里要理解调用约定、寄存器分配、指令流水线,不是常规需求,但十年经验的人知道何时需要,并能在性能瓶颈处使用。

商品过期处理:内存对齐与硬件需求

假设我们引入了一个硬件扫码枪,它要求传输的数据必须 16 字节对齐,否则会出错。这可以用 C++11 的 alignas 控制对齐:

1
2
3
4
struct alignas(16) ScanData {
char buffer[64];
uint64_t timestamp;
};

或者动态分配时用 std::aligned_alloc(C++17)。内存对齐影响 CPU 缓存行,避免伪共享(false sharing)在多线程中也很重要。比如两个原子变量在同一个缓存行,不同线程修改它们会导致缓存同步,性能骤降。可以用 alignas(64) 将它们隔开。

编译期计算升级:consteval 与 constinit

C++20 引入了 consteval(立即函数)和 constinit。比如我们想在编译期计算所有商品的折扣价表,确保运行时零开销:

1
2
3
4
5
6
7
8
consteval std::array<double, 100> generateDiscountTable() {
std::array<double, 100> table{};
for (int i = 0; i < 100; ++i) {
table[i] = i * 0.01; // 折扣率
}
return table;
}
constinit auto discountTable = generateDiscountTable(); // 静态初始化,保证初始化顺序

consteval 保证函数只在编译期调用,不会生成运行时代码。constinit 保证变量在静态初始化阶段完成,避免动态初始化的顺序问题(比如一个静态对象依赖另一个静态对象时可能出问题)。这解决了 C++ 长期以来的“静态初始化顺序灾难”。

商品数据格式化:std::format

打印日志或生成报表时,需要格式化字符串。C++20 引入了 std::format,类似 Python 的 format:

1
2
std::string msg = std::format("商品 {} 价格 {:.2f} 会员价 {:.2f}", 
name, price, memberPrice);

它比 std::stringstream 快,类型安全,且支持自定义类型的格式化(通过特化 std::formatter)。背后的实现原理是编译期解析格式字符串,生成格式化代码,避免运行时解析开销。

范围与视图:std::ranges

处理商品列表时,经常需要链式操作:过滤过期商品、排序、取前10个。C++20 的 ranges 库让代码更清晰:

1
2
3
4
5
namespace views = std::views;
auto top10 = inventory
| views::filter([](auto& p) { return !p.isExpired(); })
| views::transform(&Product::price)
| views::take(10);

views 是惰性求值的,不会创建临时容器,性能更好。背后利用了定制点对象(CPO)哨位(sentinel)技术,确保范围操作的高效和可组合性。这里要理解 range 的迭代器、sentinel 的区别,以及如何自己实现一个 view。

协程:异步处理收银

如果超市有多个收银台,我们需要异步处理结账,避免线程阻塞。C++20 的协程可以简化异步代码:

1
2
3
4
5
6
7
generator<Receipt> checkoutAsync(Queue& queue) {
while (true) {
auto items = co_await queue.nextCustomer(); // 挂起直到有顾客
Receipt r = processItems(items);
co_yield r; // 产生收据
}
}

协程通过 co_awaitco_yieldco_return 实现无栈挂起。编译器会将函数转换为状态机,把局部变量保存到堆上(除非可以优化掉)。这里要理解协程的 promise_type、handle、awaitable 接口,以及如何自定义调度器。协程能极大简化异步编程,但理解其内存分配和性能开销很重要。

模块化:C++20 Modules

超市系统日益庞大,头文件依赖导致编译缓慢。C++20 的模块可以解决:

1
2
3
4
5
6
// product.ixx
export module product;
export class Product { /*...*/ };

// main.cpp
import product;

模块可以隔离实现细节,减少宏污染,并且理论上能加速编译(因为编译器可以预编译模块接口)。但模块的二进制兼容性、与已有构建系统的集成仍是挑战,需要时间普及。

移动语义再深入:完美转发与引用折叠

在模板中,我们经常需要将参数原样传递给另一个函数,保持其左值/右值属性。这就要完美转发

1
2
3
4
template<typename... Args>
void emplaceProduct(Args&&... args) {
inventory.emplace_back(std::forward<Args>(args)...);
}

std::forward 根据模板参数类型决定是左值引用还是右值引用。这里涉及引用折叠规则:T&& 遇到左值引用会折叠成左值引用,遇到右值引用保持右值引用。std::forward 的本质就是条件转换,实现转发。如果不使用完美转发,可能会多一次拷贝,或者无法将临时对象转移到容器中。

智能指针的循环引用:weak_ptr

会员和订单可能互相引用,比如订单指向会员,会员记录订单列表。如果用 shared_ptr 会导致循环引用,内存泄漏。这时用 weak_ptr 打破循环:

1
2
3
4
5
6
7
class Member {
std::vector<std::weak_ptr<Order>> orders; // 弱引用,不影响引用计数
};

class Order {
std::shared_ptr<Member> member;
};

weak_ptr 通过 lock() 获得 shared_ptr,如果原对象已销毁,返回空。其实现依赖控制块中的弱计数,当 shared_ptr 都销毁后,若还有 weak_ptr,控制块仍存在,直到最后一个 weak_ptr 销毁才释放对象内存。这里要理解控制块的布局、原子操作对强弱计数的维护。

异常实现的成本

异常在 C++ 中常被诟病有性能开销。但现代实现(如 Itanium C++ ABI)采用零开销模型:在没有异常抛出时,正常执行路径没有额外成本;抛出异常时,需要查表(unwind table)找到合适的 catch 子句,并析构沿途对象。这依赖于编译器生成的元数据和运行时栈展开。但异常会增大二进制体积,且不适合实时系统。我们可以用 noexcept 声明不会抛出异常的函数,帮助编译器优化。

重载决议与 ADL

当调用一个函数时,编译器需要决定调用哪个重载。这个过程涉及实参依赖查找(ADL),即在参数的命名空间中查找函数。例如 std::cout << product; 能工作是因为 ADL 找到了 std::operator<<。ADL 有时会导致意外,比如 swap(a, b) 如果不在 std 下,可能会调用自定义的 swap。因此 C++ 标准库中常用 using std::swap; swap(a, b); 来同时考虑自定义和标准。

内联命名空间与 ABI 兼容

当库更新时,为了保持 ABI 兼容,可以用内联命名空间:

1
2
3
4
5
namespace product {
inline namespace v1 {
class Product { /*...*/ };
}
}

这样 product::Product 实际指向 product::v1::Product,将来可以加 v2 并内联切换,而用户代码无需修改。这要求理解符号修饰(mangling)和 ABI 稳定性的重要性。

总结:C++ 知识体系

这些特性不是孤立的,它们共同构成了 C++ 的强大和复杂。比如:

  • 模板元编程与 SFINAE/Concepts 结合,可以在编译期做类型检查和计算。
  • 移动语义与完美转发配合,实现高效泛型代码。
  • 智能指针与 RAII 管理资源,异常安全保证。
  • 原子操作与内存序,实现无锁并发。
  • 协程与异步框架,简化并发编程。
  • 模块与内联命名空间,支持大型项目演进。

十年经验的工程师不仅要会用这些,还要理解其背后的原理、适用场景、权衡取舍。比如知道何时用继承 vs 类型擦除,何时用模板 vs 虚函数,何时用原子操作 vs 锁,何时用异常 vs 错误码。这样才能写出既高效又健壮的代码。

所以,当我说“了解 C++”时,我指的是对以上这些特性有深入理解,能根据需求设计出合理的架构,并能预见到潜在的坑和优化空间。这个超市系统的例子,正是把这些知识点串起来的场景,每个需求都对应了 C++ 的一个或多个特性,每个特性都有其存在的理由和内部机理。