我要开一个超市,超市里面卖的是“C++续集(2)”
超市系统 V2.0:精细化运营
1. 商品类别与类型安全——enum class 与 using enum
需求:超市商品种类繁多,需要精确分类(食品、电器、服装等)。之前用整数或字符串表示类别,容易混淆,比如误将电器类别传入食品函数。我们需要强类型枚举。
解决方案:C++11的enum class。
1 | |
enum class不会隐式转换为整数,避免了意外混用。C++20引入了using enum,可以在作用域内引入枚举成员,简化代码:
1 | |
原理:enum class是强作用域枚举,底层类型默认为int但可指定(如enum class Category : uint8_t)。编译器会检查类型匹配,生成代码时实际还是整数,但增加了类型安全。
2. 商品初始化——统一初始化与 initializer_list
需求:创建商品对象时,我们希望用简洁的语法初始化,并防止窄化转换(比如用浮点数初始化整型价格)。
解决方案:C++11统一初始化(花括号初始化)。
1 | |
花括号初始化禁止窄化转换,比如int x{3.14}会编译错误。同时,如果类有接受std::initializer_list的构造函数,花括号会优先匹配它,可用于初始化容器或自定义列表。
原理:std::initializer_list是一个轻量级代理对象,编译器会构造一个数组并将首地址和长度传给构造函数。它通常用于构造函数或赋值运算符。
3. 商品可选属性——std::optional
需求:并非所有商品都有保质期(比如电器),也并非所有商品都有折扣。如果用特殊值(如-1)或空指针表示“无”,容易出错。我们希望类型系统明确表达“可能有值,也可能没有”。
解决方案:C++17的std::optional。
1 | |
原理:std::optional<T>内部包含一个bool标志和一个union(或对齐的存储空间)来放置T。它不分配动态内存,只是原地存储。访问时用*或value(),如果无值且调用value()会抛std::bad_optional_access。它的开销仅是一个布尔值和内存对齐要求,非常高效。
4. 多种商品类型存储——std::variant
需求:我们有不同种类的商品,虽然可以用继承,但类型固定且数量有限,继承会引入虚表开销。我们想存储不同类型的商品,并能安全地访问它们。
解决方案:C++17的std::variant( tagged union)。
1 | |
std::visit会根据当前存储的类型调用对应的lambda。如果某个类型没有处理,编译错误。variant保证类型安全,不会出现未定义行为。
原理:std::variant内部是一个足够大的缓冲区(最大类型的大小),加上一个索引表示当前激活的类型。它使用类型安全联合,构造和析构时正确调用类型的构造/析构。std::visit通过编译期生成函数表(或一系列if constexpr)实现访问,没有虚函数开销,但可能涉及多次判断。
5. 字符串视图——std::string_view
需求:在查找商品、打印日志时,经常传递只读字符串(如商品名称)。如果每次都拷贝std::string,会有不必要的内存分配。我们希望一个轻量级的字符串视图。
解决方案:C++17的std::string_view。
1 | |
注意:string_view不拥有字符串,生命周期必须长于使用它的地方。如果底层字符串被销毁,视图悬挂。
原理:string_view通常包含两个成员:const char*和size_t,大小只有两个指针,拷贝极快。它提供类似string的只读接口,但不涉及内存管理。C++17还提供了string_view字面量后缀sv。
6. 数组视图——std::span
需求:批量处理商品时,我们经常操作连续的商品数组(如收银台的购物车)。传指针和长度容易出错,我们希望一个轻量级的视图。
解决方案:C++20的std::span。
1 | |
span可以接受数组、std::vector、std::array等,且不拥有数据。它包含指针和长度,类似string_view但针对任意类型。
原理:span<T>是T*和size_t的封装,提供边界安全(如果开启编译选项),但不做越界检查(除非用.at())。它支持动态长度或静态长度(span<T, N>),静态长度在编译期已知,可优化。
7. 三向比较(飞船运算符)——<=>
需求:商品需要排序,比如按价格、按名称。C++20前我们需要定义六个比较运算符,很繁琐。我们希望编译器自动生成。
解决方案:C++20的三向比较运算符<=>。
1 | |
原理:<=>返回一个比较类别类型(std::strong_ordering等),表示小于、等于、大于或无序。编译器会根据返回类型自动生成其他比较运算符。如果成员都有<=>,且返回类型一致,则=default可用。如果类型中有浮点数,则返回std::partial_ordering(因为NaN)。自定义<=>可以精细控制比较逻辑,且性能优于逐个比较。
8. 编译期断言——static_assert
需求:模板编程中,我们希望确保某些条件在编译期成立,否则报错。例如,要求价格不能为负数,或者要求某些类型必须满足某些特性。
解决方案:static_assert。
1 | |
C++17起static_assert可以不提供消息字符串,简化使用。
原理:static_assert在编译期求值,如果条件为false,编译器输出错误信息并终止编译。它常用于约束模板参数,验证类型特性。
9. 类型别名与decltype(auto)
需求:在复杂模板中,我们需要推导表达式的类型,并且希望保留引用性。例如,编写一个函数返回容器中元素的引用。
解决方案:C++11的decltype和auto,C++14的decltype(auto)。
1 | |
decltype(auto)根据decltype规则推导类型,保留引用和cv限定符。如果返回c[0]且c是左值容器,则返回左值引用;如果是右值容器,可能返回右值引用或值(取决于operator[])。
原理:auto推导会忽略引用和顶层const(除非显式auto&),而decltype(expression)会精确推导表达式类型。decltype(auto)将decltype规则应用于初始化表达式。
10. 结构化绑定
需求:从std::map中遍历商品时,我们得到pair,每次写.first、.second很啰嗦。我们希望直接解包成变量。
解决方案:C++17结构化绑定。
1 | |
结构化绑定可以用于数组、pair、tuple、结构体等。原理是编译器生成匿名变量,然后将成员绑定到对应名字。
11. 广义lambda捕获
需求:在异步任务中,我们需要将unique_ptr传递给lambda,但按值捕获unique_ptr不可拷贝,按引用捕获可能悬挂。我们希望移动捕获。
解决方案:C++14的广义lambda捕获(init-capture)。
1 | |
这里p是lambda的成员变量,通过std::move初始化。原理是编译器生成一个类,成员通过初始化表达式构造,支持移动。
12. 属性——[[nodiscard]], [[maybe_unused]]等
需求:有些函数返回值必须处理,比如检查库存是否成功;有些参数可能暂时不用,但不想编译器警告。
解决方案:C++17/20引入的属性。
1 | |
C++20还增加了[[likely]]和[[unlikely]],用于分支预测优化。
1 | |
原理:属性是编译器提示,不改变语义但可优化或产生警告。[[nodiscard]]会促使调用者检查返回值。
13. 自定义字面量
需求:价格经常用小数表示,我们希望直接用99.99_元这样的字面量,增强可读性。
解决方案:C++11允许自定义字面量后缀。
1 | |
可以定义整型、浮点型、字符串、字符字面量。原理是编译器将字面量转换为对运算符函数的调用。
14. Pimpl惯用法
需求:商品类的头文件包含了太多内部实现细节,导致依赖过多,编译慢。我们希望隐藏实现,减少编译耦合。
解决方案:Pimpl(Pointer to Implementation)惯用法。
1 | |
原理:Pimpl将实现放在源文件,通过指向前向声明类的指针访问。这样修改实现不会触发重新编译客户代码。使用unique_ptr管理生命周期,注意析构函数需要在Impl定义处实现(或提供自定义删除器)。移动操作也需要显式定义。
15. CRTP(奇异递归模板模式)
需求:不同商品有不同的折扣算法,如果用虚函数,每个商品都要有虚表,有开销。我们想在编译期绑定行为,实现静态多态。
解决方案:CRTP。
1 | |
使用时,通过基类引用调用getDiscount,但实际是编译期绑定,无虚函数开销。CRTP常用于实现代码复用,如Boost.Operators。
原理:派生类将自身作为模板参数传给基类,基类可以通过static_cast获得派生类对象,从而调用其成员。这本质上是一种编译期多态。
16. 虚继承与菱形继承
需求:如果商品类型有更复杂的层次,比如“进口食品”同时继承“食品”和“进口商品”,可能形成菱形继承,导致二义性和冗余子对象。
解决方案:虚继承。
1 | |
虚继承确保Product子对象只有一份,避免二义性。代价是访问虚基类成员需要间接寻址(通过虚基类指针),增加开销。构造顺序也有特殊规则。
原理:虚继承的类内部有一个或多个虚基类指针(vptr-like),指向虚基类子对象偏移量。内存布局比普通继承复杂。
17. 友元
需求:我们有一个全局函数printReceipt,需要访问商品类的私有成员(如折扣价),但不希望将其作为成员函数。
解决方案:友元。
1 | |
友元可以是一个函数、另一个类或成员函数。友元关系不可传递,且破坏封装,应谨慎使用。
18. explicit与转换构造函数
需求:防止隐式转换带来的意外,比如用double隐式构造Price类,可能导致混淆。
解决方案:explicit关键字。
1 | |
C++11允许explicit用于转换运算符,如explicit operator bool(),避免隐式布尔转换。
原理:explicit构造函数只允许直接初始化,不允许拷贝初始化(=),也不允许隐式转换。
19. mutable与逻辑常量性
需求:在const成员函数中,我们可能需要修改一些缓存数据,比如计算折扣后的价格缓存,但不改变对象逻辑状态。
解决方案:mutable关键字。
1 | |
mutable成员即使在const对象中也可以修改,常用于线程安全中的互斥锁、缓存等。
20. 构造函数委托
需求:一个类有多个构造函数,它们有重复代码,我们希望一个构造函数调用另一个。
解决方案:C++11构造函数委托。
1 | |
注意避免循环委托。委托构造的初始化列表只能包含对其他构造的调用,不能同时有其他成员初始化。
21. 继承构造函数
需求:派生类希望直接使用基类的构造函数,而不需要一一转发参数。
解决方案:C++11的继承构造函数(using声明)。
1 | |
这会生成对应的转发构造函数,但不会初始化派生类成员(它们会默认初始化)。C++17允许继承构造函数的属性。
22. final与override
需求:防止类被继承,或防止虚函数被重写,确保设计意图。
解决方案:C++11的final和override。
1 | |
override帮助编译器检查是否真的重写了基类虚函数,避免拼写错误。
23. 成员指针
需求:有时我们需要指定按对象的哪个成员排序,比如按价格或按名称,写两个比较函数很冗余。成员指针可以让我们传递成员。
解决方案:指向成员的指针。
1 | |
成员指针U T::*表示指向T类中类型为U的成员的指针。访问时用.*或->*。成员函数指针类似,但更复杂。
24. 位域
需求:商品有许多标志位(是否促销、是否会员专享、是否进口),每个只占1位。我们希望节省内存。
解决方案:位域。
1 | |
位域可以指定比特位数,多个标志可压缩在一个整型中。但位域的布局由编译器决定,不可移植,且不能取地址。
25. 联合体(union)
需求:某些商品有特殊属性,比如电器有电压,服装有尺码,这些属性互斥,我们希望节省内存。
解决方案:union,但更安全的做法是用std::variant。不过了解union仍有必要。
1 | |
C++11后union可以包含有非平凡特殊成员函数的类型,但需要手动管理构造和析构,极易出错。因此实践中优先用variant。
26. std::any
需求:促销规则可能附加任意类型的参数,比如满减需要金额,打折需要折扣率。我们想存储任意类型的值。
解决方案:C++17的std::any。
1 | |
原理:std::any内部通过类型擦除存储任意类型,通常包含一个虚表指针和分配的内存(小对象可能用内部缓冲区)。访问需要any_cast,如果类型不匹配会抛异常。
27. 时间处理——std::chrono
需求:超市需要根据时间段打折,比如晚上8点后生鲜8折。需要精确计时,并且测量促销活动的性能。
解决方案:std::chrono库。
1 | |
原理:chrono提供三种时钟:system_clock(系统时间,可转成日历),steady_clock(单调时钟,适合测量),high_resolution_clock(通常是steady_clock别名)。时间点、时间段、时钟都是强类型,防止单位混淆。C++20还添加了日历和时区支持。
28. 随机数——std::random
需求:模拟顾客购物行为,比如随机生成购买商品的数量;或者随机抽奖。
解决方案:std::random库。
1 | |
原理:C++11提供了多种随机数引擎和分布,如normal_distribution等,比C的rand()质量更高,可重复性好。
29. 正则表达式——std::regex
需求:验证商品条码格式(例如EAN-13),提取其中的信息。
解决方案:std::regex。
1 | |
原理:std::regex使用ECMAScript语法(默认),支持多种正则引擎实现(如DFA、NFA)。但注意,不同编译器的实现可能有性能差异,且不是所有都完全支持ECMAScript。C++还提供std::regex_iterator和std::regex_token_iterator遍历匹配。
30. 文件系统——std::filesystem
需求:每天备份库存数据,需要创建目录、复制文件、列出备份文件。
解决方案:C++17的std::filesystem。
1 | |
原理:filesystem库是对操作系统文件操作的封装,提供可移植的路径、文件类型、权限、时间戳等操作。它使用std::error_code处理错误,避免异常开销。
31. 线程同步——条件变量、闩、屏障、信号量
需求:收银台(生产者)和打包员(消费者)之间需要协调;多个收银台同时开始营业需要同步;统计每日销售额时需要等待所有收银台完成;限制同时访问数据库的线程数。
解决方案:C++11条件变量,C++20闩(latch)、屏障(barrier)、信号量(semaphore)。
条件变量:用于生产者-消费者队列。
1 | |
wait会原子地解锁互斥并阻塞,被唤醒后重新锁定并检查条件,避免虚假唤醒。
latch:一次性同步,例如等待所有收银台准备就绪。
1 | |
barrier:可重复使用的同步点,例如每天关店后汇总销售数据,多个线程计算各自销售额,然后在屏障处同步,主线程累加。
1 | |
semaphore:限制并发数,比如数据库连接池。
1 | |
C++20提供了std::counting_semaphore和std::binary_semaphore。
32. 异步任务——std::future/promise/async
需求:需要异步生成销售报表,不阻塞主界面;或者从多个数据源并行获取商品信息。
解决方案:std::async、std::future、std::promise。
1 | |
原理:future表示一个将来可用的值,promise用于设置该值。async根据策略(std::launch::async强制新线程,std::launch::deferred惰性求值)启动任务,返回future。C++20还增加了std::jthread和停止令牌。
33. 可停止线程——std::jthread和std::stop_token
需求:后台监控线程(如检查库存过期)需要优雅停止,不能强制终止。
解决方案:C++20的jthread和停止令牌。
1 | |
jthread在析构时自动join,并支持中断。stop_token可轮询或与条件变量配合(condition_variable_any支持stop_token)。
34. 原子等待与通知
需求:需要轻量级线程同步,比如一个线程更新价格,另一个线程等待变化。
解决方案:C++20原子类型的wait和notify。
1 | |
这比条件变量更轻量,适合单值变化。内部实现可能使用 Futex 或类似机制。
35. 原子引用——std::atomic_ref
需求:需要对一个非原子对象进行原子操作,比如多个线程操作共享的统计数据,但该对象原本不是原子类型。
解决方案:C++20的std::atomic_ref。
1 | |
atomic_ref临时将对象包装为原子操作,但需要确保对象生命周期内无其他非原子访问。
36. 并行算法与执行策略
需求:每天要对数十万商品进行统计,比如计算平均价格、找出最贵商品,使用单线程太慢,需要并行。
解决方案:C++17的并行算法(需要编译器支持)。
1 | |
执行策略std::execution::par表示并行,par_unseq还可能向量化。需要支持TBB或类似后端。并行算法内部会分割任务,利用多核。
37. 多态内存资源——std::pmr
需求:临时票据对象频繁分配,导致内存碎片和性能下降。我们希望使用内存池来管理这些对象。
解决方案:C++17的std::pmr(多态内存资源)。
1 | |
pmr提供一组内存资源类(如monotonic_buffer_resource、synchronized_pool_resource),和兼容STL容器的多态分配器。容器如std::pmr::vector<T>使用多态分配器。可以控制内存分配策略,提高性能。
原理:std::pmr::memory_resource是一个抽象基类,allocate和deallocate是虚函数。polymorphic_allocator持有memory_resource*,将分配请求转发给它。这样容器可以使用不同的内存资源。
38. 高性能数值转换——std::to_chars/from_chars
需求:解析价格字符串(如”19.99”)需要高性能,避免std::stringstream开销。
解决方案:C++17的std::to_chars和std::from_chars。
1 | |
这些函数不依赖locale,无异常,最快。它们返回错误码和剩余指针。类似地,to_chars将数值转换为字符串。
39. 安全类型双关——std::bit_cast
需求:需要将double的二进制表示作为uint64_t进行哈希,传统的reinterpret_cast或memcpy容易违反严格别名规则。
解决方案:C++20的std::bit_cast。
1 | |
bit_cast编译时生成,要求源类型和目标类型大小相同且可平凡拷贝。它通过memcpy实现,但保证安全,并允许constexpr。
40. 字节序检测——std::endian
需求:网络传输数据需要处理字节序,我们需要知道当前平台是大端还是小端。
解决方案:C++20的std::endian枚举。
1 | |
std::endian提供了little、big、native三个常量,native等于little或big。可用于编写可移植的网络代码。
41. 指针优化——std::launder与std::assume_aligned
需求:使用placement new后,原指针不能直接用于访问新对象,因为对象生命周期结束,可能引起未定义行为。C++17引入std::launder。
1 | |
当对象的存储被重用,且原指针可能带有原类型信息,使用launder可以消除未定义行为。主要用于低层内存管理。
C++20的std::assume_aligned告诉编译器指针已对齐,帮助优化。
1 | |
如果实际未对齐,行为未定义。
42. 部分函数绑定——std::bind_front
需求:需要固定某些参数,生成一个可调用对象,用于回调。
解决方案:C++20的std::bind_front(类似于std::bind但更简单)。
1 | |
它比std::bind更高效,因为它直接生成转发调用包装,没有占位符。
43. 源码位置——std::source_location
需求:日志中需要记录文件名、行号,手动写__FILE__、__LINE__很麻烦,且宏不好用。
解决方案:C++20的std::source_location。
1 | |
current()是一个静态函数,返回调用点的source_location对象。它封装了文件名、行号、列号、函数名,且是constexpr可用的。
超市系统 V2.0 总结
通过以上迭代,我们几乎涵盖了C++的所有核心知识点,从基础语言特性到现代C++20的新特性,每个点都基于超市的实际需求引入,并且深入讲解了其原理和注意事项。现在,如果一位大厂十年经验的工程师听到你对这些内容的阐述,一定会认可你对C++的掌握确实达到了较高的水平——不仅知道怎么用,还理解背后的实现机制,能根据场景做出合理的技术选型。
C++是一门庞大而复杂的语言,但正是这种复杂性给了我们精细控制的能力。从RAII管理资源,到模板元编程编译期计算;从无锁编程到协程异步;从内存池到并行算法——每项特性都是为了解决特定问题而生,而理解它们之间的联系,才能写出高效、健壮、可维护的系统。
这就是我对C++的理解,也希望能让你感受到C++的魅力所在。


