信息隐藏(Information Hiding)比看起来难得多——理解
信息隐藏是软件工程的核心原则之一,但在实际开发中很难落实。指出软件工程教育标准普遍低于其他工程学科,因此开发者常常难以正确应用信息隐藏。此外,还有一些常见原因导致信息隐藏失败:
1⃣ 流程图本能(The Flowchart Instinct)
- 很多程序员习惯从流程图出发设计程序,直接把控制流写死。
- 问题:这种方法把实现细节暴露给外部,违反信息隐藏原则。
- 理解公式化:
程序结构≈控制流主导⇒模块内部实现暴露 \text{程序结构} \approx \text{控制流主导} \Rightarrow \text{模块内部实现暴露}程序结构≈控制流主导⇒模块内部实现暴露
2⃣ 为变化做计划似乎是“过度计划”(Planning for Change Seems like too much Planning)
- 开发者倾向于只做眼前需求,而忽略为未来变化设计抽象层。
- 信息隐藏要求对可能变化的部分做封装,但这在短期项目中往往被忽略。
- 逻辑关系:
不为变化设计⇒系统脆弱⇒修改困难 \text{不为变化设计} \Rightarrow \text{系统脆弱} \Rightarrow \text{修改困难}不为变化设计⇒系统脆弱⇒修改困难
3⃣ 坏模型(Bad Models)
- 如果系统模型设计不合理(例如对象、模块关系混乱),信息隐藏无法实现。
- 坏模型会导致:
- 模块耦合度高
- 内部实现暴露
- 可以表示为:
坏模型⇒高耦合⇒信息暴露 \text{坏模型} \Rightarrow \text{高耦合} \Rightarrow \text{信息暴露}坏模型⇒高耦合⇒信息暴露
4⃣ 扩展坏的软件(Extending Bad Software)
- 当基础软件设计不合理时,后续的功能扩展会增加复杂性,使得信息隐藏更难。
- 核心问题:累积的设计缺陷导致模块边界混乱。
- 表示:
坏软件基础+扩展⇒信息隐藏困难 \text{坏软件基础} + \text{扩展} \Rightarrow \text{信息隐藏困难}坏软件基础+扩展⇒信息隐藏困难
5⃣ 假设常常不被注意(Assumptions often go unnoticed)
- 开发者对系统的隐含假设往往未显式表达。
- 例子:作者的 KwIC Index 中存在重大缺陷,就是因为一些假设未被发现。
- 表示:
隐含假设∉文档/接口⇒隐藏信息被破坏 \text{隐含假设} \not\in \text{文档/接口} \Rightarrow \text{隐藏信息被破坏}隐含假设∈文档/接口⇒隐藏信息被破坏
6⃣ 将系统环境反映在软件结构中(Reflecting the system environment in the software structure)
- 一些程序设计直接依赖操作系统、硬件等环境,而非抽象接口。
- 问题:模块内部实现暴露,使信息隐藏原则失效。
- 表示:
环境依赖⇒实现暴露⇒信息隐藏失败 \text{环境依赖} \Rightarrow \text{实现暴露} \Rightarrow \text{信息隐藏失败}环境依赖⇒实现暴露⇒信息隐藏失败
7⃣ “哦,太糟了,我们改了它”(“Oh, too bad we changed it”)
- 当系统变化时,如果设计没有良好封装,会导致大面积修改。
- 体现信息隐藏失败的直接后果。
- 表示:
设计未隐藏变化点+需求变化⇒系统破坏 \text{设计未隐藏变化点} + \text{需求变化} \Rightarrow \text{系统破坏}设计未隐藏变化点+需求变化⇒系统破坏
总结
信息隐藏失败的原因大致可以归纳为:
- 设计思想问题(流程图本能、坏模型、坏软件扩展)
- 对变化缺乏前瞻(过少规划、未隐藏变化点)
- 假设未明确、过度依赖环境
核心思想:
信息隐藏=对变化点封装+抽象设计+假设显式化 \text{信息隐藏} = \text{对变化点封装} + \text{抽象设计} + \text{假设显式化}信息隐藏=对变化点封装+抽象设计+假设显式化
实现细节隐藏(Hiding Implementation Details)并不简单——理解
隐藏实现细节是软件设计的重要原则,但实际中很难完全做到。原因大致分为四类:
1⃣ 因为我们懒惰,或者认为不重要(Lazy or Not Important)
- 很多开发者在时间紧迫时会舍弃“优雅设计”,直接使用暴露的实现细节。
- 心态可能包括:
- 时间压力:没有时间做抽象设计或封装。
- 认为不重要:觉得将来可以轻松修改(现实往往不可能或很困难)。
- 例子:
std::pair<First,Second>p;autok=p.first;autov=p.second;- 尽管存在其他安全的访问方法,但直接访问
first和second仍然暴露了私有字段。 - 核心思想:
懒惰 + 时间压力⇒直接暴露实现细节 \text{懒惰 + 时间压力} \Rightarrow \text{直接暴露实现细节}懒惰+时间压力⇒直接暴露实现细节 - 演讲目的:劝告开发者不要懒惰,即使看似不重要,也要重视信息隐藏。
2⃣ 因为我们没有意识到自己暴露了私有细节(Unaware of Exposing Details)
- 开发者往往在提供接口时,无意中泄露了私有信息。
- 例子:
classFoo{std::map<std::string,int>properties;public:conststd::map<std::string,int>&getProperties()const;};- 暴露的私有细节:返回的
std::map引用允许外部直接访问和修改内部数据。 - 逻辑关系:
返回内部引用⇒外部可修改私有数据⇒信息隐藏失败 \text{返回内部引用} \Rightarrow \text{外部可修改私有数据} \Rightarrow \text{信息隐藏失败}返回内部引用⇒外部可修改私有数据⇒信息隐藏失败
3⃣ 技术约束(Technical Constraints)
- 有些语言或编译器限制,使得理想的封装方式难以实现。
- 例子:
classFoo{Foo(intvalue):value(value){}// 想让构造函数私有,但编译不通过public:staticstd::unique_ptr<Foo>create(intvalue){returnstd::make_unique<Foo>(value);}};autofoo=Foo::create(7);- 解决办法:技术约束通常可以找到替代方案,如工厂函数、智能指针等。
- 核心思想:
技术限制≠无法隐藏⇒有解决方案可用 \text{技术限制} \neq \text{无法隐藏} \Rightarrow \text{有解决方案可用}技术限制=无法隐藏⇒有解决方案可用
4⃣ 权衡折中(Tradeoffs)
- 有时为了性能或便利,需要暴露部分实现细节:
- 避免虚函数调用开销。
- 提供随机访问接口以提升性能。
- 需要进行权衡:
- 某些场景下妥协是合理的。
- 也可能存在两全其美的方案。
- 逻辑表示:
暴露数据⇔性能提升但需权衡设计原则 \text{暴露数据} \Leftrightarrow \text{性能提升} \quad \text{但需权衡设计原则}暴露数据⇔性能提升但需权衡设计原则
总结公式化
隐藏实现细节失败的原因可以表示为:
信息隐藏失败=懒惰/不重视+无意识暴露+技术约束+设计折中 \text{信息隐藏失败} = \text{懒惰/不重视} + \text{无意识暴露} + \text{技术约束} + \text{设计折中}信息隐藏失败=懒惰/不重视+无意识暴露+技术约束+设计折中
- 懒惰/不重视→ 时间压力、认为不重要
- 无意识暴露→ 返回内部引用、直接访问内部字段
- 技术约束→ 语言/编译器限制,但可解决
- 设计折中→ 性能或便利导致暴露
目标(Our Goal)
在这个例子中,我们想要:
- 分析代码示例
- 观察如何暴露实现细节(implementation details)。
- 理解为什么这是不好的设计
- 每个例子中,设计问题是什么。
- 提出改进方案
- 隐藏实现细节,同时保留所需功能。
std::pair 示例
问题:未隐藏私有成员是不正确的设计
- 观察:
std::pair.first std::pair.secondstd::pair的first和second是 public 的,这是 C++ 标准库中的设计“意外”(language accident)。
- 为什么是问题:
- 暴露数据成员无法控制行为。比如:
- 如果希望创建一个“懒计算”的
second(如它是first的平方),直接访问 public 成员会破坏这种能力。
- 如果希望创建一个“懒计算”的
- 暴露数据成员无法控制行为。比如:
- 信息隐藏原则:
成员应为 private⇒通过 getter/setter 或函数访问 \text{成员应为 private} \Rightarrow \text{通过 getter/setter 或函数访问}成员应为private⇒通过getter/setter或函数访问
例子分析
// 假设函数依赖于 PAIR 有 first 和 second 字段template<typenamePAIR>std::ostream&operator<<(std::ostream&out,constPAIR&pair){returnout<<pair.first<<' '<<pair.second;}// 使用 std::pairautostdpair=std::make_pair("bye",42);std::cout<<stdpair<<std::endl;// 如果希望 second 惰性计算?autosquarepair=SquarePair(7);std::cout<<squarepair<<std::endl;问题点
- operator<< 模板依赖 public 字段
- 这意味着只要
PAIR有first和second,无论它是 std::pair 还是自定义类型都可以被输出。 - 但如果我们想让
second是惰性计算的(如first*first),就必须隐藏内部实现,否则会被外部直接访问。
- 这意味着只要
- 扩展能力受限
- 直接暴露字段会破坏对未来行为的控制。
- 无法插入逻辑(如延迟求值、缓存结果、通知等)。
改进设计方向
- 将数据成员设为 private:
classSquarePair{private:intfirst_;intsecond_;// 可以懒计算public:SquarePair(intx):first_(x),second_(0){}intfirst()const{returnfirst_;}intsecond()const{if(second_==0)second_=first_*first_;returnsecond_;}friendstd::ostream&operator<<(std::ostream&out,constSquarePair&p){returnout<<p.first()<<' '<<p.second();}};- 好处:
- 外部无法直接访问
second_,保证惰性计算逻辑不被破坏。 - 保持接口一致性:通过函数访问,而不是直接访问成员。
- 外部无法直接访问
总结公式化
- 问题公式化:
公开成员⇒无法控制行为⇒设计不灵活 \text{公开成员} \Rightarrow \text{无法控制行为} \Rightarrow \text{设计不灵活}公开成员⇒无法控制行为⇒设计不灵活 - 改进公式化:
成员 private+getter/setter 或友元函数⇒实现细节隐藏∧可扩展性 \text{成员 private} + \text{getter/setter 或友元函数} \Rightarrow \text{实现细节隐藏} \wedge \text{可扩展性}成员private+getter/setter或友元函数⇒实现细节隐藏∧可扩展性
结构体与类设计的一般问题(structs, in general)
1. 基本 struct 示例
structOrder{Price price;Quantity quantity;};- 问题:
struct默认是public,所有成员都公开。- 当需求变化时(比如支持不同类型的订单),直接访问成员会导致修改困难。
- 难以扩展,无法在不破坏已有接口的情况下增加逻辑(如惰性计算、验证、转换)。
2. 支持泛型订单(Supporting Generic Orders)
classOrder{Price price;Quantity quantity;public:// 构造函数略PricegetPrice()const{returnprice;}QuantitygetQuantity()const{returnquantity;}};- 改进点:
- 将成员私有化(private)。
- 提供 getter 函数访问。
- 好处:
- 可以在 getter 中加入逻辑(如验证、缓存、转换)。
- 支持“静态多态(Static Polymorphism)”或“鸭子类型(Duck Typing)”,允许不同类实现同样接口。
3. 更复杂的订单类型
classTimedOrder{std::vector<std::pair<chrono::time_point,Price>>price_limits;Quantity quantity;public:PricegetPrice()const{/* 获取合适价格 */}QuantitygetQuantity()const{returnquantity;}};- 说明:
- 支持价格随时间变化的订单。
- 依然可以通过接口(getter)与普通
Order兼容。
4. const 成员的问题(Just a const member)
classPerson{std::string name;public:constId id;Person(std::string name,Id id):name(std::move(name)),id(id){}};- 问题:
const成员一旦初始化就不能修改。- 需求可能会变化,例如:
- 需要支持多种身份证明(SSN、护照等)。
- 可能需要更新护照信息,同时保留旧护照号。
- 结论:
- 直接用
const绑定会限制未来扩展。 - 正确的做法是将成员私有化,并提供可控的访问与修改接口。
- 直接用
5. 最终改进设计
classPerson{std::string name;Id id;// 不再是 constpublic:Person(std::string name,Id id):name(std::move(name)),id(id){}// 提供接口操作 IdvoidnewPassport(PassportDetails pd){id.update(pd);}constId&getId()const{returnid;}};- 好处:
- 支持未来需求变化。
- 保证实现细节隐藏,同时提供必要接口。
- 提供封装和灵活性。
6. 总结与经验(Takeaway)
- 始终将数据成员(包括 static 成员)设为 private。
- 提供受控访问接口:
- Getter / Setter
- 特定功能函数(如
newPassport())
- 避免使用
const限制未来需求扩展。 - 接口设计优先考虑可扩展性和信息隐藏。
原则公式化
- 信息隐藏:
成员 private∧接口访问⇒可控扩展∧维护性强 \text{成员 private} \wedge \text{接口访问} \Rightarrow \text{可控扩展} \wedge \text{维护性强}成员private∧接口访问⇒可控扩展∧维护性强 - 错误做法:
成员 public 或 const⇒难以扩展∧实现细节泄露 \text{成员 public 或 const} \Rightarrow \text{难以扩展} \wedge \text{实现细节泄露}成员public或const⇒难以扩展∧实现细节泄露
1. API 使用与风险
- Hyrum 定律:
“开发者会依赖接口的所有可观察特性和行为,即使这些行为没有在合同中声明。”
- 含义:
- 当一个 API 有足够多的用户时,不论你承诺什么,所有可观察的行为都会被依赖。
- 换句话说,API 的设计细节一旦暴露出来,就可能被外部代码绑定,难以修改。
- 研究发现:
- 应用通常只使用 API 的一小部分。
- 未使用的 API 更容易出错。
- API 选项过多(比如重载函数太多)容易被误用。
- 选项少不仅减少出错机会,也减少测试工作量。
2. API 设计原则:Lean & Mean
- 保持 API 简洁:
- 只添加真正需要的功能。
- 限制 API 的使用范围:
- 只暴露必要的参数和返回值。
- 不泄露实现细节。
- 根据使用场景暴露不同视图:
- 不同上下文可能只需要部分数据。
- 通过视图或包装类(Wrapper)暴露相关数据。
3. 上下文特定性(Context-specific)
- 核心思想:
- 在某些使用场景下需要将成员或方法设为
public。 - 在其他使用场景下,完全没有必要暴露。
- 在某些使用场景下需要将成员或方法设为
- 原则:
- 如果用户不需要,就不要提供。
- 如果提供了,他们会使用。
- 如果使用了,他们可能会滥用。
- 不要轻易相信外部使用者。
- 结论:
- 仅仅把成员放在
private并不够。 - API 设计必须考虑不同使用上下文,提供最窄的接口。
- 仅仅把成员放在
4. 如何传递参数,只暴露必要信息
- 方法:
- 值语义(Value semantics):
- 直接传值,需要时可封装在类中。
- 接口(Interface):
- 提供有限访问方法。
- Pimpl Idiom:
- 将实现隐藏在指针后面。
- 包装类(Wrapper/Views):
- 提供只读或部分访问。
- C++20 Concepts:
- 用类型约束提供只暴露所需的接口。
- 值语义(Value semantics):
- 示例练习:
- Const Map, Mutable Vals:
- 初始化一个 map。
- 传递方式:允许修改 map 的值,但 map 本身不能修改(如不能添加或删除 key)。
- 可用包装类或概念来实现。
- Const Map, Mutable Vals:
5. 核心 takeaway
- API 要尽量窄:只暴露必要信息。
- 上下文相关性决定什么可以暴露。
- 避免未使用的 API 或冗余接口。
- 技术手段可以帮助隐藏实现细节:
- Pimpl
- 只读视图
- 类型约束
- 信任无外人,只暴露必需部分。
公式化理解
- 上下文敏感暴露:
API exposed=f(context)where minimal exposure⇒less abuse, less bugs \text{API exposed} = f(\text{context}) \quad \text{where minimal exposure} \Rightarrow \text{less abuse, less bugs}API exposed=f(context)where minimal exposure⇒less abuse, less bugs - Hyrum 定律的启示:
任何可观察行为⇒可能被依赖⇒修改风险 \text{任何可观察行为} \Rightarrow \text{可能被依赖} \Rightarrow \text{修改风险}任何可观察行为⇒可能被依赖⇒修改风险
1. 什么是 Concepts
- 概念(Concept):
- 对模板参数的约束(constraint)。
- 在编译期求值为布尔值(boolean)。
- 可用于函数重载解析(overload resolution)。
- 优点:
- Concepts 是 SFINAE(Substitution Failure Is Not An Error)的更优雅、可读性更高的替代方案。
- 通过 Concepts,可以清晰表达“哪些类型允许被模板接受”。
- 数学理解:
Concept(T)=true/false at compile-time \text{Concept}(T) = \text{true/false at compile-time}Concept(T)=true/false at compile-time
表示模板参数是否满足约束。
2. 例子:函数模板重载
目标:实现两个print函数:
- 对可迭代类型(如
std::vector、std::array):
template<typenameIterable>voidprint(constIterable&iterable){for(constauto&v:iterable)std::cout<<v<<std::endl;}- 对其他类型:
template<typenameT>voidprint(constT&t){std::cout<<t<<std::endl;}- 问题:
- 上述代码在模板重载解析时可能出错,因为编译器无法区分是否可迭代。
- 需要 SFINAE 或 Concepts 来约束模板参数。
3. SFINAE 方案
template<typenameIterable,std::enable_if_t<is_iterable_v<Iterable>>*=nullptr>voidprint(constIterable&iterable){...}template<typenameT,std::enable_if_t<!is_iterable_v<T>>*=nullptr>voidprint(constT&t){...}- 问题:
- 写法冗长,不直观。
is_iterable_v不是语言内置,需要自定义。
4. Concepts 方案
方法 1:requires约束
template<typenameT>requiresstd::ranges::range<T>voidprint(constT&iterable){for(constauto&v:iterable)std::cout<<v<<std::endl;}template<typenameT>voidprint(constT&t){std::cout<<t<<std::endl;}- 说明:
requires表达了模板参数必须满足std::ranges::range<T>概念。- 简洁明了,比 SFINAE 可读性高。
方法 2:约束模板类型
template<std::ranges::range T>voidprint(constT&iterable){...}- 将模板参数直接声明为满足某个概念的类型。
方法 3:约束函数参数(abbreviated function template)
voidprint(conststd::ranges::rangeauto&iterable){for(constauto&v:iterable)std::cout<<v<<std::endl;}voidprint(constauto&t){std::cout<<t<<std::endl;}- 特点:
- C++20 允许在函数参数使用
auto,使函数成为模板函数。 - 这种写法叫做“简化函数模板(abbreviated function template)”,最简洁。
- C++20 允许在函数参数使用
5. 总结
- Concepts用于约束模板参数,提供更清晰、可维护的重载逻辑。
- 可以替代 SFINAE,使模板代码可读性更高。
- 对函数模板重载,可通过:
requires子句约束模板类型。- 模板参数直接约束(
template<Concept T>)。 - 函数参数约束(
auto+ 概念)实现简化。
- 使用场景:比如打印函数、容器处理函数等,需要区分可迭代类型和其他类型。
1. 创建自定义 Concept
template<typenameT>conceptMeowable=requires(constT t){t.meow();// t 必须有一个 const 方法 meow};- 解释:
concept关键字用于定义概念Meowable。requires(const T t)表示对类型T的约束条件:- 这里要求
T类型的对象t必须有一个可调用的meow()方法,并且该方法是const的。
- 这里要求
- 如果
T满足这个条件,则Meowable<T>为true,否则为false。
- 数学理解:
Meowable(T) ⟺ ∃t:T, t.meow() 是合法的且 const \text{Meowable}(T) \iff \exists t: T, \text{ t.meow() 是合法的且 const }Meowable(T)⟺∃t:T,t.meow()是合法的且const
2. 定义类型满足 Concept
structCat{voidmeow()const{std::cout<<"mewo\n";}};structFrenchCat{voidmeow()const{std::cout<<"miaou\n";}};- 说明:
Cat和FrenchCat都有一个const方法meow(),所以它们满足Meowable概念。- 如果某个类型没有
meow()方法,或者meow()不是const,就不满足该概念。
3. 使用 Concept 约束函数模板
voiddo_meow(constMeowableauto&meowable){meowable.meow();}- 解释:
const Meowable auto&是简化函数模板(abbreviated function template)的写法。- 仅允许满足
Meowable概念的类型作为参数传入。 - 编译器在调用时会检查类型是否满足概念,如果不满足,则编译失败。
- 例子调用:
Cat c;do_meow(c);// OK, Cat 满足 Meowabledo_meow(FrenchCat{});// OK, FrenchCat 满足 Meowable// do_meow(7); // 编译错误, int 不满足 Meowable4. 编译期静态断言
static_assert(Meowable<Cat>);static_assert(!Meowable<int>);static_assert(Meowable<FrenchCat>);- 作用:
static_assert在编译期检查类型是否满足概念。- 可以作为测试,保证模板约束的正确性。
- 如果断言失败,编译器报错。
5. 优势总结
- 清晰表达意图:
Meowable直接描述了“可以喵”的类型,而不需要手动写 SFINAE。
- 编译期安全:
- 不符合概念的类型直接编译错误,避免运行期错误。
- 可组合性:
- Concepts 可以组合,如
Meowable && Serializable等。
- Concepts 可以组合,如
- 简化模板重载:
- 与
auto配合使用,使模板函数声明简洁:
voiddo_meow(constMeowableauto&meowable); - 与
- 静态断言支持:
static_assert可以直接用概念检查类型是否符合约束。
6. 扩展
- 可以定义更复杂的概念,例如要求方法返回特定类型:
template<typenameT>conceptMeowableInt=requires(constT t){{t.meow()}->std::same_as<int>;};- 这里要求
t.meow()的返回类型必须是int。
总结: - 概念(Concept)是 C++20 强大的模板约束工具。
- 自定义概念可以约束类型成员函数、操作符等。
- 简化函数模板+Concepts能让模板代码既安全又可读。
1.requires的两种用法
在 C++20 中,requires主要有两种用途:
requires子句(requires clause)- 用于模板参数或函数声明,指定约束条件。
- 语法:
template<typenameT>requiresAddable<T>voidfoo(T t){...}- 含义:模板参数
T必须满足Addable<T>概念或布尔常量表达式,否则编译失败。
requires表达式(requires expression)- 用于定义自定义概念,描述某种约束。
- 语法:
template<classT>conceptFooable=requires(T t){t.foo();// T 必须有 foo() 方法};
2. requires 子句约束条件类型
requires子句必须是一个可以在编译期求值为bool的常量表达式,包括:
constexpr booltemplate<typenameT>voidfoo(T t)requiresfalse{}// 永远不满足- 布尔常量表达式
- 例如:
template<typenameT>voidfoo(T t)requires(sizeof(T)<=4){}// 限制类型大小 - 基于类型特性的布尔值
#include<type_traits>template<typenameT>voidfoo(T t)requiresstd::is_integral_v<T>{}// 必须是整数类型 - 已有概念
#include<concepts>template<typenameT>voidfoo(T t)requiresstd::integral<T>{}// T 必须是整数概念类型 - 复合约束(逻辑与/或/非)
template<typenameT>voidfoo(T t)requires(std::integral<T>&&sizeof(T)==1){}// T 必须是整数且大小为 1 字节(char 或 bool)
3. 示例分析
示例 1:类型为整数才允许调用
template<typenameT>constexprboolis_int=false;template<>constexprboolis_int<int>=true;template<typenameT>voidfoo(T t)requiresis_int<T>{}intmain(){foo(8);// ok, T=int// foo(""); // fails, T=const char* 不满足 is_int}is_int<T>是一个布尔常量表达式。requires子句会在编译期判断T是否满足条件。- 不满足时,编译器直接报错。
示例 2:限制类型大小
template<typenameT>voidfoo(T t)requires(sizeof(T)<=4){}intmain(){foo(8);// ok, sizeof(int)<=4// foo(""); // fails, sizeof(const char*)=8}requires子句可以使用常量表达式计算类型大小。- 避免运行时出错。
示例 3:组合约束
#include<concepts>template<typenameT>voidfoo(T t)requires(std::integral<T>&&sizeof(T)==1){}intmain(){foo('a');// ok, charfoo(false);// ok, bool// foo(8); // fails, int size>1// foo(""); // fails, const char* 不满足整数概念}- 复合约束可用逻辑与
&&/ 或||组合多个条件。 - 语义非常直观:类型必须同时满足所有约束。
4. 总结
requires子句- 用于模板或函数声明,限定类型必须满足条件。
- 可以使用布尔常量表达式、类型特性或概念。
requires表达式- 用于自定义概念,约束类型必须有特定成员或操作符。
- 优势:
- 编译期检查类型,避免运行期错误。
- 替代复杂的 SFINAE,语法清晰。
- 可组合和复用,增强模板代码的可读性和安全性。
- 数学形式:
假设C(T)为约束条件,则:
T 可以使用函数 foo ⟺ C(T)=true T \text{ 可以使用函数 foo } \iff C(T) = \text{true}T可以使用函数foo⟺C(T)=true
- 示例:
C(T) = (\text{std::integral<T>} \wedge \text{sizeof(T)}=1)
foo(T) 仅当 T∈char,bool foo(T) \text{ 仅当 } T \in { char, bool }foo(T)仅当T∈char,bool
1.requires expression
requires expression是定义概念(concept)的一种方式,用于描述某个类型必须满足的操作或成员存在性。
template<classT>conceptFooable=requires(T t){t.foo();// 类型 T 必须有一个 foo() 方法};- 上面代码定义了一个概念
Fooable。 requires (T t)中的语句是对类型 T 的约束。- 如果 T 不满足约束(比如没有
foo()方法),编译时就会报错。 - 注意:
requires expression并不是定义概念的唯一方法,后面还有其他方式(如布尔常量表达式或组合概念)。
2. 定义概念的基本方式
概念可以通过多种方式约束模板参数:
- 布尔常量表达式
#include<concepts>template<typenameT>conceptByteSize=(sizeof(T)==1);// T 的大小必须为 1 字节template<typenameT>voidfoo(T t)requiresByteSize<T>{}intmain(){foo('a');// okfoo(false);// ok// foo(8); // fails// foo(""); // fails}ByteSize<T>是一个布尔表达式概念。- 满足条件的类型才能调用
foo。
- 组合概念(Conjunction of concepts)
可以把已有概念和类型特性组合:#include<concepts>template<typenameT>conceptSignedByteSize=(std::is_signed_v<T>&&(sizeof(T)==1));template<typenameT>voidfoo(T t)requiresSignedByteSize<T>{}intmain(){foo('a');// ok(char 是否有符号取决于编译器)// foo(false); // fails// foo(8); // fails// foo(""); // fails}SignedByteSize限制类型既是有符号类型,又是1 字节大小。- 通过逻辑与
&&可以组合多个约束条件。
- 组合现有概念与类型操作
可以将已有概念与类型值进行组合,例如检查某个容器是否为特定元素类型:#include<vector>#include<ranges>#include<concepts>usingnamespacestd::ranges;template<typenameR,typenameE>conceptRangeOf=range<R>&&std::same_as<range_value_t<R>,E>;template<RangeOf<int>T>voidfoo(constT&t){}intmain(){std::vector<int>vec_int={1,2};std::vector<char>vec_char={'a','b'};foo(vec_int);// ok// foo(vec_char); // fails// foo(8); // fails// foo(""); // fails}RangeOf<R, E>表示类型R是一个范围(range)并且元素类型是E。- 对于不符合条件的类型,编译期直接报错。
3. 概念的作用
- 编译期约束类型,保证模板安全性。
- 避免传统 SFINAE 的复杂语法,更直观清晰。
- 可以组合不同约束,实现高度灵活的模板接口。
- 概念可用作
requires子句或auto简化模板函数。
4. 数学表示
设一个概念C(T),则:
T 可以使用函数 foo ⟺ C(T)=true T \text{ 可以使用函数 } foo \iff C(T) = \text{true}T可以使用函数foo⟺C(T)=true
- 例如:
ByteSize(T)=(sizeof(T)=1) ByteSize(T) = (sizeof(T) = 1)ByteSize(T)=(sizeof(T)=1)
SignedByteSize(T)=(std::is_signed_v<T>∧sizeof(T)=1) SignedByteSize(T) = (\text{std::is\_signed\_v<T>} \wedge sizeof(T) = 1)SignedByteSize(T)=(std::is_signed_v<T>∧sizeof(T)=1) - 调用
foo(T)的条件就是概念约束成立。
1. 基本语法
概念可以通过requires <requirements>定义,其中<requirements>是对类型或表达式的约束条件:
template<classT>conceptFooable=requires<requirements>;requirements可以采用两种形式:- 花括号体(Curly body):不带参数,仅说明类型必须满足的要求。
- 带参数列表 + 花括号体:要求类型 T 满足特定操作或成员函数存在。
- 这里的arguments are unevaluated:在
requires中的参数不会真的求值,只检查类型是否有相关成员或操作。
2. 示例解析
示例 1:静态成员函数
#include<concepts>template<typenameT>conceptFooable=requires{T::foo();};template<typenameT>voidfoo(T t)requiresFooable<T>{t.foo();}intmain(){structFoo{staticvoidfoo(){};};foo(Foo{});// ok// foo(8); // fails// foo(""); // fails}requires { T::foo(); }表示T 类型必须有一个静态成员函数foo()。- 编译器仅检查
T::foo()是否存在,不会调用它。 - 传入的
Foo满足条件,因此foo(Foo{})可以编译。 - 其他类型(如
int或const char*)不满足条件,会在编译期报错。
示例 2:普通成员函数
#include<concepts>template<typenameT>conceptFooable=requires(T t){t.foo();};template<typenameT>voidfoo(T t)requiresFooable<T>{t.foo();}intmain(){structFoo{voidfoo(){};};foo(Foo{});// ok// foo(8); // fails// foo(""); // fails}requires(T t)表示T 类型必须有一个普通成员函数foo()。- 传入对象
Foo{}可以调用成员函数foo(),因此通过概念约束。 - 对不满足条件的类型(如
int)直接编译报错。
示例 3:使用std::declval
#include<concepts>#include<utility>// for std::declvaltemplate<typenameT>conceptFooable=requires{std::declval<T>().foo();};template<typenameT>voidfoo(T t)requiresFooable<T>{t.foo();}intmain(){structFoo{voidfoo(){};};foo(Foo{});// ok// foo(8); // fails// foo(""); // fails}std::declval<T>()可以生成类型 T 的右值引用,在编译期用于检查表达式有效性,而无需构造对象。requires { std::declval<T>().foo(); }作用与示例 2 相似,但可以处理没有默认构造函数的类型。- 优点:不需要实例化对象即可检查成员函数是否存在。
3. 总结
requires expression可以用于定义概念,检查类型是否满足某些操作或成员函数存在。- 静态 vs 普通成员函数:
- 静态成员函数:
requires { T::foo(); } - 普通成员函数:
requires(T t) { t.foo(); }或requires { std::declval<T>().foo(); }
- 静态成员函数:
- 编译期检查:
- 约束在编译期生效,不会真正调用成员函数。
- 不满足约束的类型在模板实例化时直接报错。
- 优点:
- 比 SFINAE 更直观
- 可以灵活组合约束
- 支持静态或动态成员函数检查
4. 数学形式表示
- 定义概念Fooable(T)Fooable(T)Fooable(T):
Fooable(T)={true如果 T 有成员函数 foo()false否则 Fooable(T) = \begin{cases} true & \text{如果 T 有成员函数 foo()} \\ false & \text{否则} \end{cases}Fooable(T)={truefalse如果T有成员函数foo()否则 - 模板函数约束:
foo(T t) 可以调用 ⟺ Fooable(T)=true \text{foo(T t)} \text{ 可以调用 } \iff Fooable(T) = truefoo(T t)可以调用⟺Fooable(T)=true
类型 T --> 概念约束检查 --> 满足条件调用 / 不满足报错1. 概念主体的内容
在概念体 (concept body) 中,可以有多种约束形式:
- 未求值表达式(Unevaluated expressions)
- 只需要能编译即可,不会真正执行。
- 用于检查类型是否存在某个成员函数或操作。
- 内部
requires子句- 用于要求类型满足一个布尔表达式。
- 语法必须使用
requires关键字。
- 花括号表达式(Curly-braced expressions)
- 可以用于注入到其他概念中。
- 支持返回类型约束,例如
{T::bar()} -> std::same_as<int>;表示T::bar()的返回值类型必须是int。
- 内部类型约束(Internal type existence)
- 要求类型必须有内部类型,语法必须加
typename。 - 例如
typename T::inner_type;检查T是否有嵌套类型inner_type。
- 要求类型必须有内部类型,语法必须加
2. 示例解析
template<classT>conceptAllSortOfChecks=requires{T::foo();// 1. 未求值表达式requiresT::Size==2;// 2. 内部 requires 子句{T::bar()}->std::same_as<int>;// 3. 返回类型约束typenameT::inner_type;// 4. 内部类型约束};- A 类型满足全部条件:
foo()存在Size == 2bar()返回int- 有
inner_type类型
因此AllSortOfChecks<A>为true
- B 类型不满足
Size == 2→AllSortOfChecks<B>为false - C 类型不满足
bar()返回类型为int→AllSortOfChecks<C>为false - D 类型
Size不是constexpr→ 概念检查无效
3. 总结要点
- requires 关键字在概念中的两种用法:
- requires clause(在模板或函数声明上)
template<typenameT>requiresSomeConcept<T>{...} - requires expression(概念内部)
conceptC=requires(T t){t.foo();requiresT::Size==2;...};
- requires clause(在模板或函数声明上)
- 概念中表达式未求值
- 检查类型或成员是否存在,不会真正运行。
- 适合检查静态成员、普通成员、内部类型或返回类型。
- 返回类型约束
- 使用花括号表达式
{expr} -> std::same_as<Type>;指定返回类型。 - 可以保证模板参数符合预期接口。
- 使用花括号表达式
- 内部类型约束
typename T::inner_type;用于检查是否存在嵌套类型。- 必须加
typename,否则语法错误。
4. 数学形式表示
设概念AllSortOfChecks(T)定义如下:
AllSortOfChecks(T)={true,如果满足下列条件1.存在静态成员函数 T::foo()2.T::Size==23.T::bar() 返回类型为 int4.存在嵌套类型 T::innertypefalse,否则 AllSortOfChecks(T) = \begin{cases} true, & \text{如果满足下列条件} \\ & 1. \text{存在静态成员函数 } T::foo() \\ & 2. T::Size == 2 \\ & 3. T::bar() \text{ 返回类型为 } int \\ & 4. \text{存在嵌套类型 } T::inner_type \\ false, & \text{否则} \end{cases}AllSortOfChecks(T)=⎩⎨⎧true,false,如果满足下列条件1.存在静态成员函数T::foo()2.T::Size==23.T::bar()返回类型为int4.存在嵌套类型T::innertype否则
- 对每个类型 A、B、C、D,可以代入判断是否满足条件,从而决定
static_assert是否通过。
概念的模板参数 (The concept’s param)
1. 概念总是模板化的
- 每个概念 (
concept) 至少要有一个模板参数。 - 当概念直接引用某个类型时,编译器可以自动注入第一个模板参数。
示例:
// 1. 概念直接作用于模板类型参数template<Printable T>voidprint1(constT&t){cout<<t<<endl;}// 2. 概念作用于 auto 参数voidprint2(constPrintableauto&t){cout<<t<<endl;}- 解释:
Printable T会让编译器自动推导T的类型。Printable auto& t是 C++20 中“缩写函数模板”(abbreviated function template)的语法,效果类似于模板化函数,但语法更简洁。
2. 概念参数需要手动提供
- 如果概念不直接依赖模板参数类型,第一个参数不会被自动注入,需要显式指定。
示例:
template<typenameT>requiresPrintable<T>voidprint(constT&t){cout<<t<<endl;}ifconstexpr(Printable<T>){...}static_assert(Printable<int>);// OK- 在以上情况中,
Printable<T>必须显式写出类型T。
3. 多参数概念
- 一个概念可以有多个模板参数。
- 第一个参数可以自动注入,其余参数需要手动提供。
示例:Dereferenceable与DereferenceableTo
template<typenameT>conceptDereferenceable=requires(T t){*t;// t 必须可以解引用};template<typenameT,typenameValueType>conceptDereferenceableTo=Dereferenceable<T>&&std::same_as<std::remove_cvref_t<decltype(*std::declval<T>())>,ValueType>;- 说明:
Dereferenceable<T>:T 类型必须可以进行*t操作。DereferenceableTo<T, ValueType>:除了可以解引用,还要求解引用后的值类型与ValueType相同。
4. 使用示例
voidprint(constauto&v){cout<<"Simple value: "<<v<<endl;}voidprint(constDereferenceableauto&t){cout<<"Dereferenceable, inner value: "<<*t<<", at: "<<t<<endl;}voidprint(constDereferenceableTo<char>auto&t){cout<<"Dereferenceable to char: "<<t<<", at: "<<(void*)t<<endl;}intmain(){inti=8;print(i);// Simple valueprint(&i);// Dereferenceable, inner valueconstchar*s="hello";print(s);// DereferenceableTo<char>charstr[]="hi";print(str);// DereferenceableTo<char>}- 解释:
print(i):普通整型,匹配第一个函数。print(&i):指针类型,匹配Dereferenceable auto。print(s)或print(str):字符指针或字符数组,匹配DereferenceableTo<char>。
- 概念参数注入机制:编译器会自动推导
auto类型,并将其作为概念的第一个模板参数。
5. 核心总结
- 概念模板参数:
- 至少有一个模板参数。
- 第一个参数可以自动注入。
- 多参数概念中,非第一个参数必须显式提供。
- 用途:
- 对模板参数添加约束。
- 简化 SFINAE 代码,提高可读性。
- 可在函数模板重载中精确匹配不同类型。
- 实践建议:
- 尽量使用
auto缩写函数模板简化语法。 - 对复杂类型需求使用多参数概念。
- 使用
requires明确约束逻辑,避免编译错误。
- 尽量使用
练习 1:Twople<First, Second>
目标
希望定义一个概念Twople<First, Second>,可以约束一个类型是“包含两个元素的容器”,并且第一个元素类型是First,第二个元素类型是Second,示例:
voidfoo(constTwople<int,double>auto&){...}能够匹配:
std::pair<int, double>std::tuple<int, double>
出错原因
你最初写的:
voidfoo(constTwople<int,double>auto&){...}报错:
'Twople' does not name a type原因:
- C++20 中,概念必须先定义(
template<class ...> concept Twople = ...;)。 - 概念可以带模板参数,但要写成
Twople<P, First, Second>或使用auto语法时,概念参数必须正确匹配函数模板的推导规则。
正确实现示例
#include<iostream>#include<tuple>#include<concepts>usingstd::cout;usingstd::endl;// Twople 概念template<typenameP,typenameFirst,typenameSecond>conceptTwople=requires(P p){requiresstd::tuple_size<P>::value==2;// 必须有两个元素{std::get<0>(p)}->std::convertible_to<First>;// 第一个元素类型{std::get<1>(p)}->std::convertible_to<Second>;// 第二个元素类型};// 使用概念voidfoo(constTwople<auto,int,double>auto&t){cout<<"Twople<int, double>\n";}voidfoo(constauto&){cout<<"const auto&\n";}intmain(){std::pair p1{1,2.5};foo(p1);// Twople<int, double>std::pair p2{1.5,2};foo(p2);// const auto&std::tuple tup{1,2.5};foo(tup);// Twople<int, double>}- 核心:
std::tuple_size<P>::value == 2判断元素个数。std::get<0>(p)/std::get<1>(p)确认元素类型。- 使用
auto和概念模板参数结合,使类型推导自动完成。
练习 2:OneOf<Ts...>
目标
定义一个概念OneOf<Ts...>,表示模板类型必须是指定类型之一:
voidfoo(constOneOf<char,int,long>auto&);voidfoo(constOneOf<double,float>auto&);出错原因
之前报错:
'OneOf' does not name a type原因:
- 概念没有定义。
auto参数与概念模板参数没有正确结合。- C++20 支持可变参数模板概念,用于匹配任意类型列表。
正确实现示例
#include<concepts>#include<iostream>usingstd::cout;usingstd::endl;// OneOf 概念实现template<typenameT,typename...Ts>conceptOneOf=(std::same_as<T,Ts>||...);// fold expression// 使用概念voidfoo(constOneOf<char,int,long>auto&){cout<<"OneOf<char, int, long>\n";}voidfoo(constOneOf<double,float>auto&){cout<<"OneOf<double, float>\n";}voidfoo(constauto&){cout<<"const auto&\n";}intmain(){foo(3.5);// OneOf<double, float>foo(4.5f);// OneOf<double, float>std::pair p{1,2};foo(p);// const auto&foo('a');// OneOf<char, int, long>foo(1);// OneOf<char, int, long>foo(2L);// OneOf<char, int, long>foo("hello");// const auto&}- 核心:
template<typename T, typename... Ts> concept OneOf = (std::same_as<T, Ts> || ...);- fold expression
|| ...遍历可变模板参数列表,判断类型是否匹配。
- fold expression
- 将概念与
auto参数结合,实现模板函数的精确匹配。
总结
- Twople:
- 约束容器类型的元素数量和类型。
- 支持
std::pair和std::tuple。
- OneOf:
- 用于多类型选择。
- 使用 fold expression 判断类型是否匹配。
- C++20概念与 auto 函数参数结合非常灵活:
- 可以实现类型安全的函数模板重载。
- 避免复杂的 SFINAE 语法,提高可读性。
概念的非类型参数
核心思想
C++20 中的概念 (concepts):
- 第一个参数必须是类型(
type)。 - 后续参数可以是非类型参数(如整数、枚举、指针等)。
- 这允许我们在概念中加入编译期常量约束,实现更灵活的类型检查。
示例分析
template<classT,size_t MIN_SIZE,size_t MAX_SIZE>conceptSizeBetween=sizeof(T)>=MIN_SIZE&&sizeof(T)<=MAX_SIZE;解释:
T:类型参数MIN_SIZE、MAX_SIZE:非类型参数(size_t编译期常量)- 条件:
MIN_SIZE≤sizeof(T)≤MAX_SIZE\text{MIN\_SIZE} \leq \text{sizeof}(T) \leq \text{MAX\_SIZE}MIN_SIZE≤sizeof(T)≤MAX_SIZE - 如果类型
T的字节大小在[MIN_SIZE, MAX_SIZE]区间内,则SizeBetween<T, MIN_SIZE, MAX_SIZE>为true。
函数模板结合概念
voidfoo(constSizeBetween<4,16>auto&){cout<<"SizeBetween<4, 16>\n";}voidfoo(constSizeBetween<17,32>auto&){cout<<"SizeBetween<17, 32>\n";}// 所有其他情况voidfoo(constauto&){cout<<"const auto&\n";}分析:
SizeBetween<4, 16> auto&表示:- 自动推导类型
T - 前提是
sizeof(T) ∈ [4, 16]
- 自动推导类型
SizeBetween<17, 32> auto&表示:sizeof(T) ∈ [17, 32]
- 其余类型通过普通
auto&匹配。
main 函数中的调用
intmain(){foo(4.5);// double, sizeof(double) = 8 → 匹配 SizeBetween<4,16>std::pair p{1,2};// pair<int,int>, sizeof(pair) ≈ 16 → 匹配 SizeBetween<4,16>std::tuple tup{1,2.5,8};// tuple<int,double,int>, sizeof(tuple) > 16 → 匹配 SizeBetween<17,32>?foo('a');// char, sizeof(char) = 1 → 匹配 default auto}解释:
double→ 8 字节 →[4,16]→ 匹配第一个函数。std::pair<int,int>→ 假设 16 字节 → 匹配第一个函数。std::tuple<int,double,int>→ 大于 16 字节,可能匹配第二个函数(如果在 17~32 范围内)。char→ 1 字节 → 匹配默认auto&。
总结
- 概念参数设计:
- 第一个参数必须是类型。
- 后续参数可以是常量或非类型参数,用于编译期条件约束。
- 使用场景:
- 限制类型大小
- 限制数组长度
- 限制枚举值
- 函数模板匹配规则:
- C++20 会根据概念约束自动选择最匹配的模板。
- 不满足任何概念的类型,会匹配普通
auto。
目标
实现一个概念TupleOf<SIZE>,使得函数可以根据元组的元素数量(Num_Elements)进行重载:
voidfoo(constTupleOf<2>auto&);// 元素数量为2voidfoo(constTupleOf<3>auto&);// 元素数量为3要求:
- 可以匹配
std::tuple和std::pair(因为pair可视为 2 元素元组)。 - 对于非元组类型,匹配普通
auto。
核心思路
- 定义基本概念 Tuple
- 检查类型是否有
std::get<0>(t)可以访问。 - 对空元组特殊处理。
- 检查类型是否有
template<classT>conceptEmptyTuple=std::same_as<T,decltype(std::tuple())>;// 空元组template<classT>conceptTuple=EmptyTuple<T>||requires(T t){std::get<0>(t);// 至少可通过 std::get<0> 访问第一个元素};- 定义 TupleOf 概念
- 在 Tuple 基础上增加元素数量约束:
template<classT,size_t SIZE>conceptTupleOf=Tuple<T>&&std::tuple_size_v<T>==SIZE;std::tuple_size_v<T>在编译期得到元组或 pair 的元素数量。- 使用
Tuple<T>保证类型确实是一个元组或类似元组的类型(支持std::get<>)。
函数模板重载
voidfoo(constTupleOf<2>auto&t){cout<<"TupleOf<2>: "<<std::get<0>(t)<<' '<<std::get<1>(t)<<'\n';}voidfoo(constTupleOf<3>auto&t){cout<<"TupleOf<3>: "<<std::get<0>(t)<<' '<<std::get<1>(t)<<' '<<std::get<2>(t)<<'\n';}voidfoo(constEmptyTupleauto&){cout<<"empty tuple\n";}// 所有非元组类型template<typenameT>requires(!Tuple<T>)voidfoo(constT&){cout<<"const auto&\n";}匹配规则
- TupleOf<2> auto&→ 匹配元素数量为 2 的元组或 pair。
- TupleOf<3> auto&→ 匹配元素数量为 3 的元组。
- EmptyTuple auto&→ 匹配空元组。
- 其它类型→ 匹配普通模板函数。
main 测试
intmain(){std::tuple t2{1,"two"};// 2 元素 → TupleOf<2>foo(t2);// 输出 TupleOf<2>: 1 twostd::tuple t3{1,"two",3};// 3 元素 → TupleOf<3>foo(t3);// 输出 TupleOf<3>: 1 two 3std::pair p1{1,"two"};// pair → 2 元素 → TupleOf<2>foo(p1);// 输出 TupleOf<2>: 1 twofoo(7);// 非元组 → 输出 const auto&foo(std::tuple());// 空元组 → 输出 empty tuple}核心特性总结
- 概念可以有非类型参数:
TupleOf<SIZE>中SIZE是非类型模板参数。- 在编译期即可对元组长度进行约束。
- 编译期重载选择:
- 编译器根据概念判断匹配函数模板。
- 优先选择最精确匹配(例如
TupleOf<2>优于普通auto)。
- 灵活性:
- 可以扩展到任意元素数量。
- 支持
std::tuple、std::pair以及空元组。
目标
实现一个函数foo,要求:
- 可以修改映射(map)中的值。
- 不能修改映射本身(例如不能插入或删除键)。
- 支持
std::map和std::unordered_map。 - 使用C++20 concepts限制类型。
核心问题
- 普通 map 的
operator[]会在键不存在时插入元素,这会破坏“不修改 map 结构”的要求。 - 因此只能使用
find+iterator->second来修改已有的值。
Concept 定义
template<typenameT>conceptConstDictMutableVals=requires(T t,typenameT::key_type key){typenameT::iterator;// 必须有迭代器类型typenameT::key_type;// 必须有 key_typetypenameT::mapped_type;// 必须有 mapped_type// t.find(key) 返回 T::iterator{t.find(key)}->std::same_as<typenameT::iterator>;// t.find(key) 可以和 t.end() 比较t.find(key)!=t.end();};解释:
- 类型约束:
T必须有iterator、key_type、mapped_type类型。
- 操作约束:
t.find(key)必须返回迭代器。- 可以与
t.end()比较,确保可以安全判断键是否存在。
- 不允许使用
operator[]:- 避免修改 map 结构,只允许通过迭代器修改值。
函数实现
voidfoo(ConstDictMutableValsauto&d){autoitr=d.find(1);// 查找 key = 1if(itr!=d.end())// 如果存在itr->second=3;// 修改值}- 这里仅修改已有键的值。
- 使用
find而非operator[]保证 map 结构不被修改。
测试
intmain(){map<int,int>myMap1={{1,0},{2,0}};foo(myMap1);// 修改 key=1 的值for(constauto&[k,v]:myMap1)std::cout<<k<<": "<<v<<std::endl;// 输出:// 1: 3// 2: 0unordered_map<int,int>myMap2={{1,0},{2,0}};foo(myMap2);// 同样生效for(constauto&[k,v]:myMap2)std::cout<<k<<": "<<v<<std::endl;// 输出:// 1: 3// 2: 0}核心理解
- 概念约束了类型和操作:
- 使用
requires确保传入类型具备必要成员和操作。 - 这是一种精细化类型接口约束,类似静态接口(Static Interface)。
- 使用
- 可修改值,不可修改容器结构:
- 通过限制不使用
operator[],只允许find+iterator->second。
- 通过限制不使用
- 兼容性:
- 支持
std::map、std::unordered_map等标准关联容器。
- 支持
- 安全与编译期检查:
- 如果传入不符合
ConstDictMutableVals的类型,编译期报错。 - 提前避免运行时错误。
小结:
- 如果传入不符合
- 关键点:用
requires表达“允许的操作”而不是暴露整个容器。 - 概念约束帮助编译器在编译期检查类型的接口。
- 这种模式适合最小权限接口设计:只暴露函数需要的最小操作。
1⃣ 问题背景
原始做法:
Expression*e=newSum(newExp(newNumber(3),newNumber(2)),newNumber(-1));cout<<*e<<" = "<<e->eval()<<endl;deletee;问题:
- 手动管理内存:
new+delete不对称,容易内存泄漏。
- 表达式构造笨重:
- 嵌套
new太长,阅读困难。
- 嵌套
- 用户暴露实现细节:
- 用户必须知道底层用裸指针,增加认知负担。
2⃣ 使用智能指针
改为:
autoe=std::make_unique<Sum>(std::make_unique<Exp>(std::make_unique<Number>(3),std::make_unique<Number>(2)),std::make_unique<Number>(-1));cout<<*e<<" = "<<e->eval()<<endl;改进:
- 内存自动管理,无需手动
delete。 - 避免裸指针泄漏。
问题仍然存在:
- 语法仍然复杂,嵌套
make_unique太长。 - 用户仍需要关心智能指针细节(类型、移动语义)。
3⃣ 隐藏智能指针
目标代码:
autoe=Sum(Exp(Number(3),Number(2)),Number(-1));cout<<e<<" = "<<e.eval()<<endl;改进点:
- 用户完全不关心智能指针。
- 内部实现可自由选择存储策略(
unique_ptr或其他)。 - 构造语法简洁明了,接近数学表达式。
4⃣ 实现策略
template<charOp>classBinaryExpression:publicExpression{unique_ptr<Expression>e1,e2;// 私有实现,隐藏细节virtualdoubleevalImpl(doubled1,doubled2)const=0;public:template<typenameExpression1,typenameExpression2>BinaryExpression(Expression1 e1,Expression2 e2):e1(make_unique<Expression1>(std::move(e1))),e2(make_unique<Expression2>(std::move(e2))){}voidprint(ostream&out)constoverride{out<<'('<<*e1<<' '<<Op<<' '<<*e2<<')';}doubleeval()constoverride{returnevalImpl(e1->eval(),e2->eval());}};核心设计思路:
- 私有智能指针成员:
- 用户不可访问,隐藏了内存管理细节。
- 模板构造函数:
- 可以接受任意
Expression类型(如Number,Sum,Exp)。 - 内部自动转换为
unique_ptr。
- 可以接受任意
- 多态 eval:
- 调用
evalImpl实现不同运算。 - 保持接口统一。
- 调用
5⃣ 具体操作实现
structSum:publicBinaryExpression<'+'>{usingBinaryExpression<'+'>::BinaryExpression;doubleevalImpl(doubled1,doubled2)constoverride{returnd1+d2;}};structExp:publicBinaryExpression<'^'>{usingBinaryExpression<'^'>::BinaryExpression;doubleevalImpl(doubled1,doubled2)constoverride{returnstd::pow(d1,d2);}};特点:
using BinaryExpression<Op>::BinaryExpression
继承模板构造函数,让派生类直接使用模板构造器。evalImpl实现具体运算逻辑。- 用户完全不用关心指针,语法简洁。
6⃣ 使用示例
autoe=Sum(Exp(Number(3),Number(2)),Number(-1));cout<<e<<" = "<<e.eval()<<endl;// 输出: ((3 ^ 2) + (-1)) = 8优点总结:
- 隐藏实现细节:
- 用户不需要知道内部使用了
unique_ptr。
- 用户不需要知道内部使用了
- 易于维护和修改:
- 将来可以换成
shared_ptr或其他存储方式,无需修改用户代码。
- 将来可以换成
- 语法清晰:
- 接近数学表达式书写方式。
- 内存安全:
- 自动管理,无需
delete。
- 自动管理,无需
7⃣ 限制与注意事项
- 目前不能直接传递已有对象的左值,例如:
因为模板构造函数使用了autoe2=Sum(e,Number(3));// 编译失败std::move,要求传入右值。 - 可以进一步改进,添加拷贝/移动构造支持,使左值也可传递。
小结: - 本设计思想体现了“最小接口暴露 + 隐藏实现细节”的理念。
- 利用了模板 +
unique_ptr+ 多态实现安全、简洁、可扩展的表达式树。
1⃣ 隐藏继承层次细节(Hiding Inheritance Hierarchies Details)
使用的设计模式
- State Pattern(状态模式)
- 核心思想:对象行为根据其内部状态变化。
- 示例:一个
Employee无论是FullTimeEmployee、PartTimeEmployee或Contractor,对外都是Employee类型,具体行为由内部状态决定。
- Strategy Pattern(策略模式)
- 核心思想:算法或行为可以动态切换。
- 示例:
PathFinder可使用 BFS 或 DFS 算法,外部调用者只关心PathFinder,不必关心使用哪种算法。
- Factory Method(工厂方法)
- 核心思想:封装对象创建过程,让调用者不需要知道具体类型。
- 用户只得到基类或接口类型即可。
总结:通过这些设计模式,可以让外部使用者不关心继承体系的复杂性,只依赖公共接口。
2⃣ 减少返回值信息量(Conveying less information on return values)
示例 1:直接返回具体类型
vector<int>foo1(){returnvector{1,2,4};}- 优点:
- 明确返回类型。
- 缺点:
- 对调用者暴露了内部实现细节:必须是
vector<int>。 - 限制了将来可能替换成其他容器(如
std::array<int,3>或std::deque<int>)。
- 对调用者暴露了内部实现细节:必须是
示例 2:使用auto返回
autofoo2(){returnvector{1,2,4};}- 优点:
- 隐藏了具体类型,调用者不必关心返回容器类型。
- 以后可以修改返回类型而不破坏接口。
- 缺点:
- 对于使用者,无法静态限制返回类型特性(例如随机访问)。
示例 3:使用概念约束返回类型
template<typenameT,typenameElementType>conceptrandom_access_range_of=std::ranges::random_access_range<T>&&std::same_as<std::ranges::range_value_t<T>,ElementType>;random_access_range_of<int>autofoo3(){returnvector{1,2,4};}- 优点:
- 隐藏具体类型,调用者不关心容器是
vector还是array。 - 保留必要特性:保证返回类型是随机访问范围(
random_access_range) 并且元素类型是int。 - 接口清晰:使用概念表达“我需要什么性质的返回值”,而不是具体类型。
- 隐藏具体类型,调用者不关心容器是
- 缺点:
- 需要 C++20 概念支持。
- 对于简单函数可能略显复杂。
3⃣ 总结对比
| 方法 | 隐藏信息 | 类型限制 | 灵活性 |
|---|---|---|---|
vector<int> | 暴露具体类型 | 明确 | 不灵活,容器固定 |
auto | 隐藏类型 | 无法限制特性 | 灵活,可更换容器 |
concept auto | 隐藏类型 | 保证性质(如随机访问) | 灵活且安全 |
核心思想:
- 使用auto + 概念可以在隐藏实现细节的同时保留静态约束,兼顾安全与灵活性。
- 适合公共接口设计,尤其在库开发中。
1⃣ 使用概念隐藏返回值类型(Conveying less information on return values)
核心点
- 概念作为返回值类型
- C++20 支持:
random_access_range_of<int>autofoo(){...} - 自动类型推导
auto必须在函数体内实现(header 中)。 - 编译器不会检查使用者的操作是否完全符合概念限制,只保证返回值在概念约束下可用。
- C++20 支持:
- 接口隐藏与 Hyrum’s Law
- 如果暴露太多实现细节,调用者可能依赖这些细节,造成leaky abstraction。
- Hyrum’s Law: “任何你允许的使用者行为,都会被依赖”,意味着即便你只想隐藏内部实现,用户也可能依赖暴露的细节,导致维护难度增加。
- 实现隐藏的方式
- 概念返回类型(C++20)
- 优点:类型隐藏,仍可表达静态约束。
- 缺点:需要头文件内实现,编译器对使用者行为不做检查。
- 接口 + 虚函数
- 动态派发隐藏具体类型。
- 缺点:引入运行时开销。
- 包装类(Wrapper Class)
- 可封装复杂类型或行为。
- 优点:更灵活,仍可隐藏实现细节。
- 缺点:工作量稍大。
- 概念返回类型(C++20)
- 注意“泄漏的抽象”
- 典型例子:
std::vector<bool>返回的 proxy 引用。 - 用户可能依赖返回 proxy 的行为,而不是仅仅使用 bool,破坏了抽象封装。
- 典型例子:
2⃣ 隐藏辅助函数(Hiding Helper Functions)
问题示例
classThingy{public:voidfoo();// 注意:不要在调用 foo 之前调用 bar!voidbar();};foo与bar有调用顺序依赖。- 用户可以错误地直接调用
bar,导致使用错误。
尝试方案 1:组合公共函数
classThingy{voidfoo();voidbar();public:voidfoobar(){foo();bar();}};- 优点:用户只能调用
foobar(),内部顺序固定。 - 缺点:如果想动态条件执行
bar,没有灵活性。
尝试方案 2:模板函数封装
classThingy{voidfoo();voidbar();public:template<typenameFunc>voidfoobar(Func&&func){foo();if(func())bar();}};- 优点:
- 用户只能调用
foobar(),顺序安全。 - 可以传入 lambda 决定是否调用
bar(),增加灵活性。
- 用户只能调用
- 结合示例:
#include<iostream>classThingy{voidfoo(){std::cout<<"foo"<<std::endl;}voidbar(){std::cout<<"bar"<<std::endl;}public:template<typenameFunc>voidfoobar(Func&&func){foo();if(func())bar();}};intmain(){Thingy t;t.foobar([]{returntrue;});}- 输出:
foo bar- 内部实现细节被完全隐藏,调用者不能误用
foo或bar。
总结
- 隐藏返回值类型:
- 用概念、接口或包装类隐藏实现,避免泄露具体类型。
- 注意 Hyrum’s Law:用户可能依赖暴露的行为。
- 隐藏辅助函数:
- 将多个相关函数封装到单个公共接口中。
- 可使用模板 + 回调函数实现灵活控制,同时保证调用顺序安全。
- 典型模式:命令封装 + 回调。
- 设计理念:
- 封装内部实现,让接口保持简单。
- 避免用户依赖内部实现细节,减少维护风险。
- 灵活性与安全性的平衡是关键。
1⃣ 核心思想
这个方案通过返回一个临时对象(临时类型BarCallable)来隐藏辅助函数的调用顺序和访问限制。
classThingy{voidinner_foo(){std::cout<<"foo"<<std::endl;}voidbar(){std::cout<<"bar"<<std::endl;}public:autofoo(){inner_foo();// 内部先执行 foo 的逻辑structBarCallable{Thingy*t;voidbar(){t->bar();}// 只能通过临时对象调用 bar};returnBarCallable(this);}};inner_foo()是私有函数,用户不能直接调用。bar()也是私有函数,用户不能直接调用。foo()是公共接口,调用后返回一个局部 struct 类型对象BarCallable。- 用户想调用
bar()时,只能通过foo()返回的BarCallable对象:
t.foo().bar();// 调用顺序被强制:必须先调用 foo()- 如果用户只是调用
t.foo();,只执行inner_foo(),bar()不会被调用。
2⃣ 特性和优点
- 强制调用顺序
- 用户不能直接调用
bar()。 bar()只能通过foo()返回的对象调用,保证了bar()前必先执行inner_foo()。
- 用户不能直接调用
- 隐藏内部实现
inner_foo()和bar()都是私有的,实现细节不暴露给用户。
- 可链式调用
- 通过返回临时对象,可写成:
t.foo().bar();
- 通过返回临时对象,可写成:
- 灵活性
- 用户可以选择只调用
foo():t.foo();// 只执行 inner_foo() - 或者调用
bar():t.foo().bar();// 执行 inner_foo() 后执行 bar()
- 用户可以选择只调用
3⃣ 与前面模板回调方法对比
| 特性 | 模板回调方法 | 临时对象返回方法 |
|---|---|---|
| 隐藏顺序 | 调用模板回调保证顺序 | 返回对象调用保证顺序 |
| 灵活性 | 回调可控制是否执行 bar | 可选择是否调用返回对象的 bar |
| 编译时类型依赖 | 编译时确定是否调用 bar | 编译时确定 BarCallable 类型 |
| 代码可读性 | 中等 | 高,可链式调用,接口清晰 |
| 私有函数保护 | 内部函数不暴露 | 内部函数不暴露 |
4⃣ 使用示例
intmain(){Thingy t;t.foo().bar();// 输出: foo bart.foo();// 输出: foo}- 输出顺序:
foo bar foo- 符合预期:
bar()只能在foo()执行后被调用。- 用户无法错误地单独调用
bar()或inner_foo()。
5⃣ 总结
这种“返回临时对象控制访问”的方法是一种安全封装辅助函数的高级技巧:
- 让内部实现细节保持私有。
- 强制执行调用顺序。
- 支持链式调用和可选调用。
- 接口对用户更直观、易用,同时内部逻辑安全。
可以看作是“临时代理对象模式”(Temporary Proxy Object Pattern),在 C++ 中非常适合隐藏实现和约束调用顺序。
1⃣ 使用protected的原则与技巧
1.1 数据成员尽量私有
classParent{Wallet wallet;// 私有protected:shortneed_money(longdouble,conststd::string&reason);// 受保护};- 原则:数据成员应尽量声明为
private,避免子类直接访问。 - 受保护成员函数(
protected)可被子类调用,但不对外公开。 - 对比:
这种方式会暴露内部状态给子类,违反封装原则。classParent{protected:Wallet wallet;// 不推荐};
1.2 调用示例
classChild:publicParent{public:voidendless_celebration(longdoubleamount){need_money(amount,"celebration");// 安全访问受保护方法}};- 子类通过
protected方法访问内部状态或逻辑,而不直接暴露数据成员。
2⃣ 测试私有行为的建议
2.1 尽量避免直接测试私有数据
- 私有状态(如类在状态 A)通常不应直接测试。
- 如果必须测试:
- 不要将成员改为
public。 - 可以添加公共验证方法,用于状态检查或日志跟踪。
- 不要将成员改为
2.2 不通过测试修改私有状态
- 测试中直接修改私有数据通常是不好的:
- 不测试实际流程。
- 可能导致测试不稳定或不真实。
- 如果测试难以实现,可能说明代码耦合过高,需要重构或设计更好的解耦。
2.3 可行方法
- 使用模拟(mocking)或构造错误场景,通过正常接口触发状态变化,而不是直接修改私有数据。
- 如果仍需访问私有成员,可以通过友元测试或特定访问接口,而不是直接暴露数据。
3⃣ 使用命名空间和上下文隐藏实现
- 利用命名空间和嵌套类型隐藏类型和实现细节。
- 私有嵌套类型可用于内部代理对象。
- C++20 模块提供更强的隐藏能力:
- 可以隐藏模板实现。
- 避免过多暴露内部类型。
4⃣ Private Token Idiom(私有令牌模式)
4.1 问题
- 想让构造函数私有化,但仍希望使用
make_unique创建对象:classFoo{public:Foo(intvalue):value(value){}// 想私有化staticstd::unique_ptr<Foo>create(intvalue){returnstd::make_unique<Foo>(value);}}; - 直接私有化构造函数会导致
make_unique编译失败。
4.2 解决方案:私有令牌模式
- 定义一个私有嵌套类型
PrivateToken:classFoo{intvalue;classPrivateToken{};// 私有令牌public:Foo(intvalue,PrivateToken):value(value){}// 仅接受令牌构造staticstd::unique_ptr<Foo>create(intvalue){returnstd::make_unique<Foo>(value,PrivateToken{});// 使用令牌创建}intgetValue()const{returnvalue;}}; - 用户不能直接构造
Foo:// Foo f(7); // 编译错误 - 必须通过
create工厂函数创建:autofoo=Foo::create(7);// 安全创建
4.3 优点
- 隐藏实现细节
- 构造函数仅可通过私有令牌访问。
- 强制工厂函数使用
- 确保对象创建逻辑一致。
- 避免接口泄露
- 用户看不到内部实现或构造方式。
5⃣ 总结
- protected:
- 数据成员尽量
private。 - 函数可适当
protected提供给子类。
- 数据成员尽量
- 测试私有行为:
- 避免直接测试私有数据。
- 使用公共接口或模拟。
- 命名空间与模块:
- 提供上下文,隐藏内部实现。
- Private Token Idiom:
- 工厂函数模式 + 私有令牌。
- 隐藏构造函数实现,强制安全对象创建。
这个模式非常适合:
- 想隐藏构造逻辑或敏感接口
- 强制使用工厂函数创建对象
- 保持类的封装性与安全性
1⃣ 隐藏实现细节的重要性
- 核心思想:隐藏实现细节不仅仅是设计建议,更是提高代码健壮性和可维护性的重要手段。
- 好的隐藏可以:
- 降低外部对内部实现的依赖。
- 内部修改不会影响外部代码。
- 提高调试和测试的便利性。
2⃣ 隐藏规则与设计原则的关系
2.1 封装 (Encapsulation)
- 保护对象完整性,只暴露必要接口。
- 内部变化不影响外部使用。
- 示例:
- 数据成员尽量
private。 - 提供
protected或公共函数来访问内部逻辑,而不是暴露内部数据。
- 数据成员尽量
2.2 解耦 (Decoupling)
- 组件只依赖于必要接口。
- 系统各部分修改不会传导影响。
- 组件更通用,测试更容易。
- 使用概念、接口、工厂函数、私有令牌模式等都属于解耦策略。
3⃣ 私有字段并不够
- 私有字段是必要条件,但不是充分条件。
- 为什么?
- 上下文决定了哪些内容应该被公开。
- 如果不需要,就不提供。
- 一旦提供,用户可能会滥用。
- 原则:
Trust no one! \text{Trust no one!}Trust no one!
不假设用户会按规矩使用接口。
4⃣ 保持抽象 (Keep Abstractions)
- 抽象 ≠ 模糊。
- 抽象的目的是创建新的语义层次,在更高层次上精确表达概念。
- 引用 Dijkstra 的观点:
The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise.
- 应用:
- 使用接口、概念或工厂函数隐藏实现。
- 使用私有类型、嵌套类、模块化等手段控制可见性。
5⃣ 避免错误理由暴露内部实现
- 有些开发者会为了“方便”而公开内部实现,但大多数理由都是错误的。
- 正确做法:
- 仔细判断哪些内容不应该暴露。
- 保持接口最小化和精确。
- 提前预防未来的缺陷和不必要的重构。
6⃣ 小结原则
- 封装优先:私有字段、受保护方法、公共接口。
- 解耦组件:只暴露必需的行为。
- 上下文敏感:不同使用场景可能需要不同可见性。
- 保持抽象:抽象提供精确语义,而不是模糊表达。
- 最小暴露:如果不需要,就不要公开。
- 工具支持:命名空间、嵌套类型、C++20 模块、概念、私有令牌模式等。
7⃣ 实际代码示例
私有令牌模式(Private Token Idiom)
classFoo{intvalue;classPrivateToken{};// 私有令牌Foo(intvalue,PrivateToken):value(value){}// 构造函数仅可由令牌调用public:staticstd::unique_ptr<Foo>create(intvalue){returnstd::make_unique<Foo>(value,PrivateToken{});}intgetValue()const{returnvalue;}};autofoo=Foo::create(7);// 安全创建- 隐藏构造函数实现。
- 强制通过工厂函数创建对象。
- 避免滥用和接口泄露。
总结:
隐藏实现细节的目标是安全、可维护、可复用的代码。 - 私有字段和保护成员只是基础。
- 抽象、工厂、概念和模块是高级工具。
- 保持接口最小化,暴露必要行为,才能有效控制复杂性。