CppCon 2024 学习:Hiding your Implementation Details
信息隐藏是软件工程的核心原则之一,但在实际开发中很难落实。指出软件工程教育标准普遍低于其他工程学科,因此开发者常常难以正确应用信息隐藏。此外,还有一些常见原因导致信息隐藏失败:信息隐藏失败的原因大致可以归纳为:隐藏实现细节是软件设计的重要原则,但实际中很难完全做到。原因大致分为四类:尽管存在其他安全的访问方法,但直接访问和仍然暴露了私有字段。核心思想:懒惰 + 时间压力⇒直接暴露实现细节\text
信息隐藏(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;
auto k = p.first;
auto v = p.second;
- 尽管存在其他安全的访问方法,但直接访问
first和second仍然暴露了私有字段。 - 核心思想:
懒惰 + 时间压力⇒直接暴露实现细节 \text{懒惰 + 时间压力} \Rightarrow \text{直接暴露实现细节} 懒惰 + 时间压力⇒直接暴露实现细节 - 演讲目的:劝告开发者不要懒惰,即使看似不重要,也要重视信息隐藏。
2⃣ 因为我们没有意识到自己暴露了私有细节(Unaware of Exposing Details)
- 开发者往往在提供接口时,无意中泄露了私有信息。
- 例子:
class Foo {
std::map<std::string, int> properties;
public:
const std::map<std::string,int>& getProperties() const;
};
- 暴露的私有细节:返回的
std::map引用允许外部直接访问和修改内部数据。 - 逻辑关系:
返回内部引用⇒外部可修改私有数据⇒信息隐藏失败 \text{返回内部引用} \Rightarrow \text{外部可修改私有数据} \Rightarrow \text{信息隐藏失败} 返回内部引用⇒外部可修改私有数据⇒信息隐藏失败
3⃣ 技术约束(Technical Constraints)
- 有些语言或编译器限制,使得理想的封装方式难以实现。
- 例子:
class Foo {
Foo(int value) : value(value) {} // 想让构造函数私有,但编译不通过
public:
static std::unique_ptr<Foo> create(int value) {
return std::make_unique<Foo>(value);
}
};
auto foo = 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<typename PAIR>
std::ostream& operator<<(std::ostream& out, const PAIR& pair) {
return out << pair.first << ' ' << pair.second;
}
// 使用 std::pair
auto stdpair = std::make_pair("bye", 42);
std::cout << stdpair << std::endl;
// 如果希望 second 惰性计算?
auto squarepair = SquarePair(7);
std::cout << squarepair << std::endl;
问题点
- operator<< 模板依赖 public 字段
- 这意味着只要
PAIR有first和second,无论它是 std::pair 还是自定义类型都可以被输出。 - 但如果我们想让
second是 惰性计算 的(如first*first),就必须隐藏内部实现,否则会被外部直接访问。
- 这意味着只要
- 扩展能力受限
- 直接暴露字段会破坏对未来行为的控制。
- 无法插入逻辑(如延迟求值、缓存结果、通知等)。
改进设计方向
- 将数据成员设为 private:
class SquarePair {
private:
int first_;
int second_; // 可以懒计算
public:
SquarePair(int x) : first_(x), second_(0) {}
int first() const { return first_; }
int second() const {
if(second_ == 0) second_ = first_ * first_;
return second_;
}
friend std::ostream& operator<<(std::ostream& out, const SquarePair& p) {
return out << 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 示例
struct Order {
Price price;
Quantity quantity;
};
- 问题:
struct默认是public,所有成员都公开。- 当需求变化时(比如支持不同类型的订单),直接访问成员会导致修改困难。
- 难以扩展,无法在不破坏已有接口的情况下增加逻辑(如惰性计算、验证、转换)。
2. 支持泛型订单(Supporting Generic Orders)
class Order {
Price price;
Quantity quantity;
public:
// 构造函数略
Price getPrice() const { return price; }
Quantity getQuantity() const { return quantity; }
};
- 改进点:
- 将成员私有化(private)。
- 提供 getter 函数访问。
- 好处:
- 可以在 getter 中加入逻辑(如验证、缓存、转换)。
- 支持“静态多态(Static Polymorphism)”或“鸭子类型(Duck Typing)”,允许不同类实现同样接口。
3. 更复杂的订单类型
class TimedOrder {
std::vector<std::pair<chrono::time_point, Price>> price_limits;
Quantity quantity;
public:
Price getPrice() const { /* 获取合适价格 */ }
Quantity getQuantity() const { return quantity; }
};
- 说明:
- 支持价格随时间变化的订单。
- 依然可以通过接口(getter)与普通
Order兼容。
4. const 成员的问题(Just a const member)
class Person {
std::string name;
public:
const Id id;
Person(std::string name, Id id)
: name(std::move(name)), id(id) {}
};
- 问题:
const成员一旦初始化就不能修改。- 需求可能会变化,例如:
- 需要支持多种身份证明(SSN、护照等)。
- 可能需要更新护照信息,同时保留旧护照号。
- 结论:
- 直接用
const绑定会限制未来扩展。 - 正确的做法是将成员私有化,并提供可控的访问与修改接口。
- 直接用
5. 最终改进设计
class Person {
std::string name;
Id id; // 不再是 const
public:
Person(std::string name, Id id)
: name(std::move(name)), id(id) {}
// 提供接口操作 Id
void newPassport(PassportDetails pd) { id.update(pd); }
const Id& getId() const { return id; }
};
- 好处:
- 支持未来需求变化。
- 保证实现细节隐藏,同时提供必要接口。
- 提供封装和灵活性。
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<typename Iterable>
void print(const Iterable& iterable) {
for(const auto& v: iterable)
std::cout << v << std::endl;
}
- 对其他类型:
template<typename T>
void print(const T& t) {
std::cout << t << std::endl;
}
- 问题:
- 上述代码在模板重载解析时可能出错,因为编译器无法区分是否可迭代。
- 需要 SFINAE 或 Concepts 来约束模板参数。
3. SFINAE 方案
template<typename Iterable, std::enable_if_t<is_iterable_v<Iterable>>* = nullptr>
void print(const Iterable& iterable) { ... }
template<typename T, std::enable_if_t<!is_iterable_v<T>>* = nullptr>
void print(const T& t) { ... }
- 问题:
- 写法冗长,不直观。
is_iterable_v不是语言内置,需要自定义。
4. Concepts 方案
方法 1:requires 约束
template<typename T> requires std::ranges::range<T>
void print(const T& iterable) {
for(const auto& v: iterable)
std::cout << v << std::endl;
}
template<typename T>
void print(const T& t) {
std::cout << t << std::endl;
}
- 说明:
requires表达了模板参数必须满足std::ranges::range<T>概念。- 简洁明了,比 SFINAE 可读性高。
方法 2:约束模板类型
template<std::ranges::range T>
void print(const T& iterable) { ... }
- 将模板参数直接声明为满足某个概念的类型。
方法 3:约束函数参数(abbreviated function template)
void print(const std::ranges::range auto& iterable) {
for(const auto& v: iterable)
std::cout << v << std::endl;
}
void print(const auto& 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<typename T>
concept Meowable = requires(const T 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
struct Cat {
void meow() const { std::cout << "mewo\n"; }
};
struct FrenchCat {
void meow() const { std::cout << "miaou\n"; }
};
- 说明:
Cat和FrenchCat都有一个const方法meow(),所以它们满足Meowable概念。- 如果某个类型没有
meow()方法,或者meow()不是const,就不满足该概念。
3. 使用 Concept 约束函数模板
void do_meow(const Meowable auto& meowable) {
meowable.meow();
}
- 解释:
const Meowable auto&是 简化函数模板(abbreviated function template) 的写法。- 仅允许满足
Meowable概念的类型作为参数传入。 - 编译器在调用时会检查类型是否满足概念,如果不满足,则编译失败。
- 例子调用:
Cat c;
do_meow(c); // OK, Cat 满足 Meowable
do_meow(FrenchCat{}); // OK, FrenchCat 满足 Meowable
// do_meow(7); // 编译错误, int 不满足 Meowable
4. 编译期静态断言
static_assert(Meowable<Cat>);
static_assert(!Meowable<int>);
static_assert(Meowable<FrenchCat>);
- 作用:
static_assert在编译期检查类型是否满足概念。- 可以作为测试,保证模板约束的正确性。
- 如果断言失败,编译器报错。
5. 优势总结
- 清晰表达意图:
Meowable直接描述了“可以喵”的类型,而不需要手动写 SFINAE。
- 编译期安全:
- 不符合概念的类型直接编译错误,避免运行期错误。
- 可组合性:
- Concepts 可以组合,如
Meowable && Serializable等。
- Concepts 可以组合,如
- 简化模板重载:
- 与
auto配合使用,使模板函数声明简洁:
void do_meow(const Meowable auto& meowable); - 与
- 静态断言支持:
static_assert可以直接用概念检查类型是否符合约束。
6. 扩展
- 可以定义更复杂的概念,例如要求方法返回特定类型:
template<typename T>
concept MeowableInt = requires(const T t) {
{ t.meow() } -> std::same_as<int>;
};
- 这里要求
t.meow()的返回类型必须是int。
总结: - 概念(Concept) 是 C++20 强大的模板约束工具。
- 自定义概念 可以约束类型成员函数、操作符等。
- 简化函数模板 + Concepts 能让模板代码既安全又可读。
1. requires 的两种用法
在 C++20 中,requires 主要有两种用途:
requires子句(requires clause)- 用于模板参数或函数声明,指定约束条件。
- 语法:
template<typename T> requires Addable<T> void foo(T t) { ... }- 含义:模板参数
T必须满足Addable<T>概念或布尔常量表达式,否则编译失败。
requires表达式(requires expression)- 用于定义自定义概念,描述某种约束。
- 语法:
template<class T> concept Fooable = requires (T t) { t.foo(); // T 必须有 foo() 方法 };
2. requires 子句约束条件类型
requires 子句必须是一个可以在编译期求值为 bool 的常量表达式,包括:
constexpr booltemplate<typename T> void foo(T t) requires false {} // 永远不满足- 布尔常量表达式
- 例如:
template<typename T> void foo(T t) requires (sizeof(T) <= 4) {} // 限制类型大小 - 基于类型特性的布尔值
#include <type_traits> template<typename T> void foo(T t) requires std::is_integral_v<T> {} // 必须是整数类型 - 已有概念
#include <concepts> template<typename T> void foo(T t) requires std::integral<T> {} // T 必须是整数概念类型 - 复合约束(逻辑与/或/非)
template<typename T> void foo(T t) requires (std::integral<T> && sizeof(T) == 1) {} // T 必须是整数且大小为 1 字节(char 或 bool)
3. 示例分析
示例 1:类型为整数才允许调用
template<typename T>
constexpr bool is_int = false;
template<>
constexpr bool is_int<int> = true;
template<typename T>
void foo(T t) requires is_int<T> {}
int main() {
foo(8); // ok, T=int
// foo(""); // fails, T=const char* 不满足 is_int
}
is_int<T>是一个布尔常量表达式。requires子句会在编译期判断T是否满足条件。- 不满足时,编译器直接报错。
示例 2:限制类型大小
template<typename T>
void foo(T t) requires (sizeof(T) <= 4) {}
int main() {
foo(8); // ok, sizeof(int)<=4
// foo(""); // fails, sizeof(const char*)=8
}
requires子句可以使用 常量表达式计算类型大小。- 避免运行时出错。
示例 3:组合约束
#include <concepts>
template<typename T>
void foo(T t) requires (std::integral<T> && sizeof(T) == 1) {}
int main() {
foo('a'); // ok, char
foo(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<class T>
concept Fooable = requires (T t) {
t.foo(); // 类型 T 必须有一个 foo() 方法
};
- 上面代码定义了一个概念
Fooable。 requires (T t)中的语句是对类型 T 的约束。- 如果 T 不满足约束(比如没有
foo()方法),编译时就会报错。 - 注意:
requires expression并不是定义概念的唯一方法,后面还有其他方式(如布尔常量表达式或组合概念)。
2. 定义概念的基本方式
概念可以通过多种方式约束模板参数:
- 布尔常量表达式
#include <concepts> template<typename T> concept ByteSize = (sizeof(T) == 1); // T 的大小必须为 1 字节 template<typename T> void foo(T t) requires ByteSize<T> {} int main() { foo('a'); // ok foo(false); // ok // foo(8); // fails // foo(""); // fails }ByteSize<T>是一个布尔表达式概念。- 满足条件的类型才能调用
foo。
- 组合概念(Conjunction of concepts)
可以把已有概念和类型特性组合:#include <concepts> template<typename T> concept SignedByteSize = (std::is_signed_v<T> && (sizeof(T) == 1)); template<typename T> void foo(T t) requires SignedByteSize<T> {} int main() { foo('a'); // ok(char 是否有符号取决于编译器) // foo(false); // fails // foo(8); // fails // foo(""); // fails }SignedByteSize限制类型既是有符号类型,又是1 字节大小。- 通过逻辑与
&&可以组合多个约束条件。
- 组合现有概念与类型操作
可以将已有概念与类型值进行组合,例如检查某个容器是否为特定元素类型:#include <vector> #include <ranges> #include <concepts> using namespace std::ranges; template<typename R, typename E> concept RangeOf = range<R> && std::same_as<range_value_t<R>, E>; template<RangeOf<int> T> void foo(const T& t) {} int main() { 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<class T>
concept Fooable = requires <requirements>;
requirements可以采用两种形式:- 花括号体(Curly body):不带参数,仅说明类型必须满足的要求。
- 带参数列表 + 花括号体:要求类型 T 满足特定操作或成员函数存在。
- 这里的 arguments are unevaluated:在
requires中的参数不会真的求值,只检查类型是否有相关成员或操作。
2. 示例解析
示例 1:静态成员函数
#include <concepts>
template<typename T>
concept Fooable = requires {
T::foo();
};
template<typename T>
void foo(T t) requires Fooable<T> {
t.foo();
}
int main() {
struct Foo { static void foo() {}; };
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<typename T>
concept Fooable = requires(T t) {
t.foo();
};
template<typename T>
void foo(T t) requires Fooable<T> {
t.foo();
}
int main() {
struct Foo { void foo() {}; };
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::declval
template<typename T>
concept Fooable = requires {
std::declval<T>().foo();
};
template<typename T>
void foo(T t) requires Fooable<T> {
t.foo();
}
int main() {
struct Foo { void foo() {}; };
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) = true foo(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<class T>
concept AllSortOfChecks = requires {
T::foo(); // 1. 未求值表达式
requires T::Size == 2; // 2. 内部 requires 子句
{T::bar()} -> std::same_as<int>; // 3. 返回类型约束
typename T::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<typename T> requires SomeConcept<T> {...} - requires expression(概念内部)
concept C = requires(T t) { t.foo(); requires T::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>
void print1(const T& t) {
cout << t << endl;
}
// 2. 概念作用于 auto 参数
void print2(const Printable auto& t) {
cout << t << endl;
}
- 解释:
Printable T会让编译器自动推导T的类型。Printable auto& t是 C++20 中“缩写函数模板”(abbreviated function template)的语法,效果类似于模板化函数,但语法更简洁。
2. 概念参数需要手动提供
- 如果概念不直接依赖模板参数类型,第一个参数不会被自动注入,需要显式指定。
示例:
template<typename T> requires Printable<T>
void print(const T& t) {
cout << t << endl;
}
if constexpr (Printable<T>) { ... }
static_assert(Printable<int>); // OK
- 在以上情况中,
Printable<T>必须显式写出类型T。
3. 多参数概念
- 一个概念可以有多个模板参数。
- 第一个参数可以自动注入,其余参数需要手动提供。
示例:Dereferenceable 与 DereferenceableTo
template<typename T>
concept Dereferenceable = requires(T t) {
*t; // t 必须可以解引用
};
template<typename T, typename ValueType>
concept DereferenceableTo =
Dereferenceable<T> &&
std::same_as<std::remove_cvref_t<decltype(*std::declval<T>())>, ValueType>;
- 说明:
Dereferenceable<T>:T 类型必须可以进行*t操作。DereferenceableTo<T, ValueType>:除了可以解引用,还要求解引用后的值类型与ValueType相同。
4. 使用示例
void print(const auto& v) {
cout << "Simple value: " << v << endl;
}
void print(const Dereferenceable auto& t) {
cout << "Dereferenceable, inner value: " << *t << ", at: " << t << endl;
}
void print(const DereferenceableTo<char> auto& t) {
cout << "Dereferenceable to char: " << t << ", at: " << (void*)t << endl;
}
int main() {
int i = 8;
print(i); // Simple value
print(&i); // Dereferenceable, inner value
const char* s = "hello";
print(s); // DereferenceableTo<char>
char str[] = "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,示例:
void foo(const Twople<int, double> auto&) { ... }
能够匹配:
std::pair<int, double>std::tuple<int, double>
出错原因
你最初写的:
void foo(const Twople<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>
using std::cout;
using std::endl;
// Twople 概念
template<typename P, typename First, typename Second>
concept Twople = requires(P p) {
requires std::tuple_size<P>::value == 2; // 必须有两个元素
{ std::get<0>(p) } -> std::convertible_to<First>; // 第一个元素类型
{ std::get<1>(p) } -> std::convertible_to<Second>; // 第二个元素类型
};
// 使用概念
void foo(const Twople<auto, int, double> auto& t) {
cout << "Twople<int, double>\n";
}
void foo(const auto&) {
cout << "const auto&\n";
}
int main() {
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...>,表示模板类型必须是指定类型之一:
void foo(const OneOf<char, int, long> auto&);
void foo(const OneOf<double, float> auto&);
出错原因
之前报错:
'OneOf' does not name a type
原因:
- 概念没有定义。
auto参数与概念模板参数没有正确结合。- C++20 支持可变参数模板概念,用于匹配任意类型列表。
正确实现示例
#include <concepts>
#include <iostream>
using std::cout;
using std::endl;
// OneOf 概念实现
template<typename T, typename... Ts>
concept OneOf = (std::same_as<T, Ts> || ...); // fold expression
// 使用概念
void foo(const OneOf<char, int, long> auto&) {
cout << "OneOf<char, int, long>\n";
}
void foo(const OneOf<double, float> auto&) {
cout << "OneOf<double, float>\n";
}
void foo(const auto&) {
cout << "const auto&\n";
}
int main() {
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<class T, size_t MIN_SIZE, size_t MAX_SIZE>
concept SizeBetween = 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。
函数模板结合概念
void foo(const SizeBetween<4, 16> auto&) {
cout << "SizeBetween<4, 16>\n";
}
void foo(const SizeBetween<17, 32> auto&) {
cout << "SizeBetween<17, 32>\n";
}
// 所有其他情况
void foo(const auto&) {
cout << "const auto&\n";
}
分析:
SizeBetween<4, 16> auto&表示:- 自动推导类型
T - 前提是
sizeof(T) ∈ [4, 16]
- 自动推导类型
SizeBetween<17, 32> auto&表示:sizeof(T) ∈ [17, 32]
- 其余类型通过普通
auto&匹配。
main 函数中的调用
int main() {
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)进行重载:
void foo(const TupleOf<2> auto&); // 元素数量为2
void foo(const TupleOf<3> auto&); // 元素数量为3
要求:
- 可以匹配
std::tuple和std::pair(因为pair可视为 2 元素元组)。 - 对于非元组类型,匹配普通
auto。
核心思路
- 定义基本概念 Tuple
- 检查类型是否有
std::get<0>(t)可以访问。 - 对空元组特殊处理。
- 检查类型是否有
template<class T>
concept EmptyTuple = std::same_as<T, decltype(std::tuple())>; // 空元组
template<class T>
concept Tuple = EmptyTuple<T> || requires(T t) {
std::get<0>(t); // 至少可通过 std::get<0> 访问第一个元素
};
- 定义 TupleOf 概念
- 在 Tuple 基础上增加元素数量约束:
template<class T, size_t SIZE>
concept TupleOf = Tuple<T> && std::tuple_size_v<T> == SIZE;
std::tuple_size_v<T>在编译期得到元组或 pair 的元素数量。- 使用
Tuple<T>保证类型确实是一个元组或类似元组的类型(支持std::get<>)。
函数模板重载
void foo(const TupleOf<2> auto& t) {
cout << "TupleOf<2>: "
<< std::get<0>(t) << ' '
<< std::get<1>(t) << '\n';
}
void foo(const TupleOf<3> auto& t) {
cout << "TupleOf<3>: "
<< std::get<0>(t) << ' '
<< std::get<1>(t) << ' '
<< std::get<2>(t) << '\n';
}
void foo(const EmptyTuple auto&) {
cout << "empty tuple\n";
}
// 所有非元组类型
template<typename T> requires (!Tuple<T>)
void foo(const T&) {
cout << "const auto&\n";
}
匹配规则
- TupleOf<2> auto& → 匹配元素数量为 2 的元组或 pair。
- TupleOf<3> auto& → 匹配元素数量为 3 的元组。
- EmptyTuple auto& → 匹配空元组。
- 其它类型 → 匹配普通模板函数。
main 测试
int main() {
std::tuple t2 {1, "two"}; // 2 元素 → TupleOf<2>
foo(t2); // 输出 TupleOf<2>: 1 two
std::tuple t3 {1, "two", 3}; // 3 元素 → TupleOf<3>
foo(t3); // 输出 TupleOf<3>: 1 two 3
std::pair p1 {1, "two"}; // pair → 2 元素 → TupleOf<2>
foo(p1); // 输出 TupleOf<2>: 1 two
foo(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 <typename T>
concept ConstDictMutableVals = requires(T t, typename T::key_type key) {
typename T::iterator; // 必须有迭代器类型
typename T::key_type; // 必须有 key_type
typename T::mapped_type; // 必须有 mapped_type
// t.find(key) 返回 T::iterator
{ t.find(key) } -> std::same_as<typename T::iterator>;
// t.find(key) 可以和 t.end() 比较
t.find(key) != t.end();
};
解释:
- 类型约束:
T必须有iterator、key_type、mapped_type类型。
- 操作约束:
t.find(key)必须返回迭代器。- 可以与
t.end()比较,确保可以安全判断键是否存在。
- 不允许使用
operator[]:- 避免修改 map 结构,只允许通过迭代器修改值。
函数实现
void foo(ConstDictMutableVals auto& d) {
auto itr = d.find(1); // 查找 key = 1
if(itr != d.end()) // 如果存在
itr->second = 3; // 修改值
}
- 这里仅修改已有键的值。
- 使用
find而非operator[]保证 map 结构不被修改。
测试
int main() {
map<int, int> myMap1 = {{1, 0}, {2, 0}};
foo(myMap1); // 修改 key=1 的值
for (const auto& [k, v] : myMap1)
std::cout << k << ": " << v << std::endl;
// 输出:
// 1: 3
// 2: 0
unordered_map<int, int> myMap2 = {{1, 0}, {2, 0}};
foo(myMap2); // 同样生效
for (const auto& [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 = new Sum(
new Exp(new Number(3), new Number(2)),
new Number(-1)
);
cout << *e << " = " << e->eval() << endl;
delete e;
问题:
- 手动管理内存:
new+delete不对称,容易内存泄漏。
- 表达式构造笨重:
- 嵌套
new太长,阅读困难。
- 嵌套
- 用户暴露实现细节:
- 用户必须知道底层用裸指针,增加认知负担。
2⃣ 使用智能指针
改为:
auto e = 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⃣ 隐藏智能指针
目标代码:
auto e = Sum(Exp(Number(3), Number(2)), Number(-1));
cout << e << " = " << e.eval() << endl;
改进点:
- 用户完全不关心智能指针。
- 内部实现可自由选择存储策略(
unique_ptr或其他)。 - 构造语法简洁明了,接近数学表达式。
4⃣ 实现策略
template<char Op>
class BinaryExpression: public Expression {
unique_ptr<Expression> e1, e2; // 私有实现,隐藏细节
virtual double evalImpl(double d1, double d2) const = 0;
public:
template<typename Expression1, typename Expression2>
BinaryExpression(Expression1 e1, Expression2 e2)
: e1(make_unique<Expression1>(std::move(e1))),
e2(make_unique<Expression2>(std::move(e2))) {}
void print(ostream& out) const override {
out << '(' << *e1 << ' ' << Op << ' ' << *e2 << ')';
}
double eval() const override {
return evalImpl(e1->eval(), e2->eval());
}
};
核心设计思路:
- 私有智能指针成员:
- 用户不可访问,隐藏了内存管理细节。
- 模板构造函数:
- 可以接受任意
Expression类型(如Number,Sum,Exp)。 - 内部自动转换为
unique_ptr。
- 可以接受任意
- 多态 eval:
- 调用
evalImpl实现不同运算。 - 保持接口统一。
- 调用
5⃣ 具体操作实现
struct Sum: public BinaryExpression<'+'> {
using BinaryExpression<'+'>::BinaryExpression;
double evalImpl(double d1, double d2) const override {
return d1 + d2;
}
};
struct Exp: public BinaryExpression<'^'> {
using BinaryExpression<'^'>::BinaryExpression;
double evalImpl(double d1, double d2) const override {
return std::pow(d1, d2);
}
};
特点:
using BinaryExpression<Op>::BinaryExpression
继承模板构造函数,让派生类直接使用模板构造器。evalImpl实现具体运算逻辑。- 用户完全不用关心指针,语法简洁。
6⃣ 使用示例
auto e = Sum(Exp(Number(3), Number(2)), Number(-1));
cout << e << " = " << e.eval() << endl; // 输出: ((3 ^ 2) + (-1)) = 8
优点总结:
- 隐藏实现细节:
- 用户不需要知道内部使用了
unique_ptr。
- 用户不需要知道内部使用了
- 易于维护和修改:
- 将来可以换成
shared_ptr或其他存储方式,无需修改用户代码。
- 将来可以换成
- 语法清晰:
- 接近数学表达式书写方式。
- 内存安全:
- 自动管理,无需
delete。
- 自动管理,无需
7⃣ 限制与注意事项
- 目前 不能直接传递已有对象的左值,例如:
因为模板构造函数使用了auto e2 = 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() {
return vector{1, 2, 4};
}
- 优点:
- 明确返回类型。
- 缺点:
- 对调用者暴露了内部实现细节:必须是
vector<int>。 - 限制了将来可能替换成其他容器(如
std::array<int,3>或std::deque<int>)。
- 对调用者暴露了内部实现细节:必须是
示例 2:使用 auto 返回
auto foo2() {
return vector{1, 2, 4};
}
- 优点:
- 隐藏了具体类型,调用者不必关心返回容器类型。
- 以后可以修改返回类型而不破坏接口。
- 缺点:
- 对于使用者,无法静态限制返回类型特性(例如随机访问)。
示例 3:使用概念约束返回类型
template<typename T, typename ElementType>
concept random_access_range_of =
std::ranges::random_access_range<T> &&
std::same_as<std::ranges::range_value_t<T>, ElementType>;
random_access_range_of<int> auto foo3() {
return vector{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> auto foo() { ... } - 自动类型推导
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)
问题示例
class Thingy {
public:
void foo();
// 注意:不要在调用 foo 之前调用 bar!
void bar();
};
foo与bar有调用顺序依赖。- 用户可以错误地直接调用
bar,导致使用错误。
尝试方案 1:组合公共函数
class Thingy {
void foo();
void bar();
public:
void foobar() { foo(); bar(); }
};
- 优点:用户只能调用
foobar(),内部顺序固定。 - 缺点:如果想动态条件执行
bar,没有灵活性。
尝试方案 2:模板函数封装
class Thingy {
void foo();
void bar();
public:
template<typename Func>
void foobar(Func&& func) {
foo();
if(func()) bar();
}
};
- 优点:
- 用户只能调用
foobar(),顺序安全。 - 可以传入 lambda 决定是否调用
bar(),增加灵活性。
- 用户只能调用
- 结合示例:
#include <iostream>
class Thingy {
void foo() { std::cout << "foo" << std::endl; }
void bar() { std::cout << "bar" << std::endl; }
public:
template<typename Func>
void foobar(Func&& func) {
foo();
if(func()) bar();
}
};
int main() {
Thingy t;
t.foobar([] { return true; });
}
- 输出:
foo
bar
- 内部实现细节被完全隐藏,调用者不能误用
foo或bar。
总结
- 隐藏返回值类型:
- 用概念、接口或包装类隐藏实现,避免泄露具体类型。
- 注意 Hyrum’s Law:用户可能依赖暴露的行为。
- 隐藏辅助函数:
- 将多个相关函数封装到单个公共接口中。
- 可使用模板 + 回调函数实现灵活控制,同时保证调用顺序安全。
- 典型模式:命令封装 + 回调。
- 设计理念:
- 封装内部实现,让接口保持简单。
- 避免用户依赖内部实现细节,减少维护风险。
- 灵活性与安全性的平衡是关键。
1⃣ 核心思想
这个方案通过 返回一个临时对象(临时类型 BarCallable)来隐藏辅助函数的调用顺序和访问限制。
class Thingy {
void inner_foo() { std::cout << "foo" << std::endl; }
void bar() { std::cout << "bar" << std::endl; }
public:
auto foo() {
inner_foo(); // 内部先执行 foo 的逻辑
struct BarCallable {
Thingy* t;
void bar() { t->bar(); } // 只能通过临时对象调用 bar
};
return BarCallable(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⃣ 使用示例
int main() {
Thingy t;
t.foo().bar(); // 输出: foo bar
t.foo(); // 输出: foo
}
- 输出顺序:
foo
bar
foo
- 符合预期:
bar()只能在foo()执行后被调用。- 用户无法错误地单独调用
bar()或inner_foo()。
5⃣ 总结
这种 “返回临时对象控制访问” 的方法是一种 安全封装辅助函数 的高级技巧:
- 让内部实现细节保持私有。
- 强制执行调用顺序。
- 支持链式调用和可选调用。
- 接口对用户更直观、易用,同时内部逻辑安全。
可以看作是 “临时代理对象模式”(Temporary Proxy Object Pattern),在 C++ 中非常适合隐藏实现和约束调用顺序。
1⃣ 使用 protected 的原则与技巧
1.1 数据成员尽量私有
class Parent {
Wallet wallet; // 私有
protected:
short need_money(long double, const std::string& reason); // 受保护
};
- 原则:数据成员应尽量声明为
private,避免子类直接访问。 - 受保护成员函数(
protected)可被子类调用,但不对外公开。 - 对比:
这种方式会暴露内部状态给子类,违反封装原则。class Parent { protected: Wallet wallet; // 不推荐 };
1.2 调用示例
class Child: public Parent {
public:
void endless_celebration(long double amount) {
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创建对象:class Foo { public: Foo(int value) : value(value) {} // 想私有化 static std::unique_ptr<Foo> create(int value) { return std::make_unique<Foo>(value); } }; - 直接私有化构造函数会导致
make_unique编译失败。
4.2 解决方案:私有令牌模式
- 定义一个 私有嵌套类型
PrivateToken:class Foo { int value; class PrivateToken {}; // 私有令牌 public: Foo(int value, PrivateToken) : value(value) {} // 仅接受令牌构造 static std::unique_ptr<Foo> create(int value) { return std::make_unique<Foo>(value, PrivateToken{}); // 使用令牌创建 } int getValue() const { return value; } }; - 用户不能直接构造
Foo:// Foo f(7); // 编译错误 - 必须通过
create工厂函数创建:auto foo = 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)
class Foo {
int value;
class PrivateToken {}; // 私有令牌
Foo(int value, PrivateToken) : value(value) {} // 构造函数仅可由令牌调用
public:
static std::unique_ptr<Foo> create(int value) {
return std::make_unique<Foo>(value, PrivateToken{});
}
int getValue() const { return value; }
};
auto foo = Foo::create(7); // 安全创建
- 隐藏构造函数实现。
- 强制通过工厂函数创建对象。
- 避免滥用和接口泄露。
总结:
隐藏实现细节的目标是 安全、可维护、可复用 的代码。 - 私有字段和保护成员只是基础。
- 抽象、工厂、概念和模块是高级工具。
- 保持接口最小化,暴露必要行为,才能有效控制复杂性。
更多推荐
所有评论(0)