信息隐藏(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{系统破坏} 设计未隐藏变化点+需求变化系统破坏

总结

信息隐藏失败的原因大致可以归纳为:

  1. 设计思想问题(流程图本能、坏模型、坏软件扩展)
  2. 对变化缺乏前瞻(过少规划、未隐藏变化点)
  3. 假设未明确、过度依赖环境
    核心思想:
    信息隐藏=对变化点封装+抽象设计+假设显式化 \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;
  • 尽管存在其他安全的访问方法,但直接访问 firstsecond 仍然暴露了私有字段。
  • 核心思想:
    懒惰 + 时间压力⇒直接暴露实现细节 \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)

在这个例子中,我们想要:

  1. 分析代码示例
    • 观察如何暴露实现细节(implementation details)。
  2. 理解为什么这是不好的设计
    • 每个例子中,设计问题是什么。
  3. 提出改进方案
    • 隐藏实现细节,同时保留所需功能。

std::pair 示例

问题:未隐藏私有成员是不正确的设计

  • 观察
    std::pair.first
    std::pair.second
    
    • std::pairfirstsecond 是 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;

问题点

  1. operator<< 模板依赖 public 字段
    • 这意味着只要 PAIRfirstsecond,无论它是 std::pair 还是自定义类型都可以被输出。
    • 但如果我们想让 second惰性计算 的(如 first*first),就必须隐藏内部实现,否则会被外部直接访问。
  2. 扩展能力受限
    • 直接暴露字段会破坏对未来行为的控制。
    • 无法插入逻辑(如延迟求值、缓存结果、通知等)。

改进设计方向

  1. 将数据成员设为 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();
    }
};
  1. 好处
    • 外部无法直接访问 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)

  1. 始终将数据成员(包括 static 成员)设为 private
  2. 提供受控访问接口:
    • Getter / Setter
    • 特定功能函数(如 newPassport()
  3. 避免使用 const 限制未来需求扩展。
  4. 接口设计优先考虑可扩展性和信息隐藏。

原则公式化

  • 信息隐藏
    成员 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 的设计细节一旦暴露出来,就可能被外部代码绑定,难以修改。
  • 研究发现
    1. 应用通常只使用 API 的一小部分。
    2. 未使用的 API 更容易出错。
    3. API 选项过多(比如重载函数太多)容易被误用。
    4. 选项少不仅减少出错机会,也减少测试工作量。

2. API 设计原则:Lean & Mean

  1. 保持 API 简洁
    • 只添加真正需要的功能。
  2. 限制 API 的使用范围
    • 只暴露必要的参数和返回值。
    • 不泄露实现细节。
  3. 根据使用场景暴露不同视图
    • 不同上下文可能只需要部分数据。
    • 通过视图或包装类(Wrapper)暴露相关数据。

3. 上下文特定性(Context-specific)

  • 核心思想
    • 在某些使用场景下需要将成员或方法设为 public
    • 在其他使用场景下,完全没有必要暴露。
  • 原则
    1. 如果用户不需要,就不要提供。
    2. 如果提供了,他们会使用。
    3. 如果使用了,他们可能会滥用。
    4. 不要轻易相信外部使用者。
  • 结论
    • 仅仅把成员放在 private 并不够。
    • API 设计必须考虑不同使用上下文,提供最窄的接口。

4. 如何传递参数,只暴露必要信息

  • 方法
    1. 值语义(Value semantics)
      • 直接传值,需要时可封装在类中。
    2. 接口(Interface)
      • 提供有限访问方法。
    3. Pimpl Idiom
      • 将实现隐藏在指针后面。
    4. 包装类(Wrapper/Views)
      • 提供只读或部分访问。
    5. C++20 Concepts
      • 用类型约束提供只暴露所需的接口。
  • 示例练习
    • Const Map, Mutable Vals
      • 初始化一个 map。
      • 传递方式:允许修改 map 的值,但 map 本身不能修改(如不能添加或删除 key)。
      • 可用包装类或概念来实现。

5. 核心 takeaway

  1. API 要尽量窄:只暴露必要信息。
  2. 上下文相关性决定什么可以暴露。
  3. 避免未使用的 API 或冗余接口。
  4. 技术手段可以帮助隐藏实现细节:
    • Pimpl
    • 只读视图
    • 类型约束
  5. 信任无外人,只暴露必需部分。

公式化理解

  • 上下文敏感暴露
    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 exposureless abuse, less bugs
  • Hyrum 定律的启示
    任何可观察行为⇒可能被依赖⇒修改风险 \text{任何可观察行为} \Rightarrow \text{可能被依赖} \Rightarrow \text{修改风险} 任何可观察行为可能被依赖修改风险

1. 什么是 Concepts

  • 概念(Concept)
    1. 对模板参数的约束(constraint)。
    2. 在编译期求值为布尔值(boolean)。
    3. 可用于函数重载解析(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 函数:

  1. 对可迭代类型(如 std::vectorstd::array):
template<typename Iterable>
void print(const Iterable& iterable) {
    for(const auto& v: iterable)
        std::cout << v << std::endl;
}
  1. 对其他类型:
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)”,最简洁。

5. 总结

  1. Concepts 用于约束模板参数,提供更清晰、可维护的重载逻辑。
  2. 可以替代 SFINAE,使模板代码可读性更高。
  3. 对函数模板重载,可通过:
    • requires 子句约束模板类型。
    • 模板参数直接约束(template<Concept T>)。
    • 函数参数约束(auto + 概念)实现简化。
  4. 使用场景:比如打印函数、容器处理函数等,需要区分可迭代类型和其他类型。

1. 创建自定义 Concept

template<typename T>
concept Meowable = requires(const T t) {
    t.meow(); // t 必须有一个 const 方法 meow
};
  • 解释
    1. concept 关键字用于定义概念 Meowable
    2. requires(const T t) 表示对类型 T 的约束条件:
      • 这里要求 T 类型的对象 t 必须有一个可调用的 meow() 方法,并且该方法是 const 的。
    3. 如果 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"; }
};
  • 说明
    • CatFrenchCat 都有一个 const 方法 meow(),所以它们满足 Meowable 概念。
    • 如果某个类型没有 meow() 方法,或者 meow() 不是 const,就不满足该概念。

3. 使用 Concept 约束函数模板

void do_meow(const Meowable auto& meowable) {
    meowable.meow();
}
  • 解释
    1. const Meowable auto&简化函数模板(abbreviated function template) 的写法。
    2. 仅允许满足 Meowable 概念的类型作为参数传入。
    3. 编译器在调用时会检查类型是否满足概念,如果不满足,则编译失败。
  • 例子调用
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. 优势总结

  1. 清晰表达意图
    • Meowable 直接描述了“可以喵”的类型,而不需要手动写 SFINAE。
  2. 编译期安全
    • 不符合概念的类型直接编译错误,避免运行期错误。
  3. 可组合性
    • Concepts 可以组合,如 Meowable && Serializable 等。
  4. 简化模板重载
    • auto 配合使用,使模板函数声明简洁:
    void do_meow(const Meowable auto& meowable);
    
  5. 静态断言支持
    • 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 主要有两种用途:

  1. requires 子句(requires clause)
    • 用于模板参数或函数声明,指定约束条件。
    • 语法:
    template<typename T> requires Addable<T>
    void foo(T t) { ... }
    
    • 含义:模板参数 T 必须满足 Addable<T> 概念或布尔常量表达式,否则编译失败。
  2. requires 表达式(requires expression)
    • 用于定义自定义概念,描述某种约束。
    • 语法:
    template<class T>
    concept Fooable = requires (T t) {
        t.foo(); // T 必须有 foo() 方法
    };
    

2. requires 子句约束条件类型

requires 子句必须是一个可以在编译期求值为 bool 的常量表达式,包括:

  1. constexpr bool
    template<typename T>
    void foo(T t) requires false {}  // 永远不满足
    
  2. 布尔常量表达式
    • 例如:
    template<typename T>
    void foo(T t) requires (sizeof(T) <= 4) {} // 限制类型大小
    
  3. 基于类型特性的布尔值
    #include <type_traits>
    template<typename T>
    void foo(T t) requires std::is_integral_v<T> {} // 必须是整数类型
    
  4. 已有概念
    #include <concepts>
    template<typename T>
    void foo(T t) requires std::integral<T> {} // T 必须是整数概念类型
    
  5. 复合约束(逻辑与/或/非)
    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. 总结

  1. requires 子句
    • 用于模板或函数声明,限定类型必须满足条件。
    • 可以使用布尔常量表达式、类型特性或概念。
  2. requires 表达式
    • 用于自定义概念,约束类型必须有特定成员或操作符。
  3. 优势
    • 编译期检查类型,避免运行期错误。
    • 替代复杂的 SFINAE,语法清晰。
    • 可组合和复用,增强模板代码的可读性和安全性。
  4. 数学形式
    假设 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) 仅当 Tchar,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. 定义概念的基本方式

概念可以通过多种方式约束模板参数:

  1. 布尔常量表达式
    #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
  2. 组合概念(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 字节大小
    • 通过逻辑与 && 可以组合多个约束条件。
  3. 组合现有概念与类型操作
    可以将已有概念与类型值进行组合,例如检查某个容器是否为特定元素类型:
    #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 可以使用函数 fooC(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 可以采用两种形式:
    1. 花括号体(Curly body):不带参数,仅说明类型必须满足的要求。
    2. 带参数列表 + 花括号体:要求类型 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{}) 可以编译。
  • 其他类型(如 intconst 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. 总结

  1. requires expression 可以用于定义概念,检查类型是否满足某些操作或成员函数存在。
  2. 静态 vs 普通成员函数
    • 静态成员函数:requires { T::foo(); }
    • 普通成员函数:requires(T t) { t.foo(); }requires { std::declval<T>().foo(); }
  3. 编译期检查
    • 约束在编译期生效,不会真正调用成员函数。
    • 不满足约束的类型在模板实例化时直接报错。
  4. 优点
    • 比 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) 中,可以有多种约束形式:

  1. 未求值表达式(Unevaluated expressions)
    • 只需要能编译即可,不会真正执行。
    • 用于检查类型是否存在某个成员函数或操作。
  2. 内部 requires 子句
    • 用于要求类型满足一个布尔表达式。
    • 语法必须使用 requires 关键字。
  3. 花括号表达式(Curly-braced expressions)
    • 可以用于注入到其他概念中。
    • 支持返回类型约束,例如 {T::bar()} -> std::same_as<int>; 表示 T::bar() 的返回值类型必须是 int
  4. 内部类型约束(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 == 2
    • bar() 返回 int
    • inner_type 类型
      因此 AllSortOfChecks<A>true
  • B 类型 不满足 Size == 2AllSortOfChecks<B>false
  • C 类型 不满足 bar() 返回类型为 intAllSortOfChecks<C>false
  • D 类型 Size 不是 constexpr → 概念检查无效

3. 总结要点

  1. requires 关键字在概念中的两种用法
    • requires clause(在模板或函数声明上)
      template<typename T> requires SomeConcept<T> {...}
      
    • requires expression(概念内部)
      concept C = requires(T t) { t.foo(); requires T::Size==2; ... };
      
  2. 概念中表达式未求值
    • 检查类型或成员是否存在,不会真正运行。
    • 适合检查静态成员、普通成员、内部类型或返回类型。
  3. 返回类型约束
    • 使用花括号表达式 {expr} -> std::same_as<Type>; 指定返回类型。
    • 可以保证模板参数符合预期接口。
  4. 内部类型约束
    • 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. 多参数概念

  • 一个概念可以有多个模板参数。
  • 第一个参数可以自动注入,其余参数需要手动提供。
示例:DereferenceableDereferenceableTo
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>
}
  • 解释:
    1. print(i):普通整型,匹配第一个函数。
    2. print(&i):指针类型,匹配 Dereferenceable auto
    3. print(s)print(str):字符指针或字符数组,匹配 DereferenceableTo<char>
  • 概念参数注入机制:编译器会自动推导 auto 类型,并将其作为概念的第一个模板参数。

5. 核心总结

  1. 概念模板参数
    • 至少有一个模板参数。
    • 第一个参数可以自动注入。
    • 多参数概念中,非第一个参数必须显式提供。
  2. 用途
    • 对模板参数添加约束。
    • 简化 SFINAE 代码,提高可读性。
    • 可在函数模板重载中精确匹配不同类型。
  3. 实践建议
    • 尽量使用 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

原因:

  1. C++20 中,概念必须先定义(template<class ...> concept Twople = ...;)。
  2. 概念可以带模板参数,但要写成 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

原因:

  1. 概念没有定义。
  2. auto 参数与概念模板参数没有正确结合。
  3. 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 || ... 遍历可变模板参数列表,判断类型是否匹配。
    • 将概念与 auto 参数结合,实现模板函数的精确匹配。

总结

  1. Twople
    • 约束容器类型的元素数量和类型。
    • 支持 std::pairstd::tuple
  2. OneOf
    • 用于多类型选择。
    • 使用 fold expression 判断类型是否匹配。
  3. C++20 概念与 auto 函数参数结合非常灵活:
    • 可以实现类型安全的函数模板重载。
    • 避免复杂的 SFINAE 语法,提高可读性。

概念的非类型参数

核心思想

C++20 中的 概念 (concepts)

  1. 第一个参数必须是 类型type)。
  2. 后续参数可以是 非类型参数(如整数、枚举、指针等)。
  3. 这允许我们在概念中加入编译期常量约束,实现更灵活的类型检查。

示例分析

template<class T, size_t MIN_SIZE, size_t MAX_SIZE>
concept SizeBetween = sizeof(T) >= MIN_SIZE && sizeof(T) <= MAX_SIZE; 
解释:
  1. T:类型参数
  2. MIN_SIZEMAX_SIZE:非类型参数(size_t 编译期常量)
  3. 条件:
    MIN_SIZE≤sizeof(T)≤MAX_SIZE\text{MIN\_SIZE} \leq \text{sizeof}(T) \leq \text{MAX\_SIZE}MIN_SIZEsizeof(T)MAX_SIZE
  4. 如果类型 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";
}
分析:
  1. SizeBetween<4, 16> auto& 表示:
    • 自动推导类型 T
    • 前提是 sizeof(T) ∈ [4, 16]
  2. SizeBetween<17, 32> auto& 表示:
    • sizeof(T) ∈ [17, 32]
  3. 其余类型通过普通 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&

总结

  1. 概念参数设计
    • 第一个参数必须是类型。
    • 后续参数可以是常量或非类型参数,用于编译期条件约束。
  2. 使用场景
    • 限制类型大小
    • 限制数组长度
    • 限制枚举值
  3. 函数模板匹配规则
    • C++20 会根据概念约束自动选择最匹配的模板。
    • 不满足任何概念的类型,会匹配普通 auto

目标

实现一个概念 TupleOf<SIZE>,使得函数可以根据元组的元素数量(Num_Elements)进行重载:

void foo(const TupleOf<2> auto&); // 元素数量为2
void foo(const TupleOf<3> auto&); // 元素数量为3

要求:

  • 可以匹配 std::tuplestd::pair(因为 pair 可视为 2 元素元组)。
  • 对于非元组类型,匹配普通 auto

核心思路

  1. 定义基本概念 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> 访问第一个元素
};
  1. 定义 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";
}

匹配规则

  1. TupleOf<2> auto& → 匹配元素数量为 2 的元组或 pair。
  2. TupleOf<3> auto& → 匹配元素数量为 3 的元组。
  3. EmptyTuple auto& → 匹配空元组。
  4. 其它类型 → 匹配普通模板函数。

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
}

核心特性总结

  1. 概念可以有非类型参数
    • TupleOf<SIZE>SIZE 是非类型模板参数。
    • 在编译期即可对元组长度进行约束。
  2. 编译期重载选择
    • 编译器根据概念判断匹配函数模板。
    • 优先选择最精确匹配(例如 TupleOf<2> 优于普通 auto)。
  3. 灵活性
    • 可以扩展到任意元素数量。
    • 支持 std::tuplestd::pair 以及空元组。

目标

实现一个函数 foo,要求:

  1. 可以修改映射(map)中的值
  2. 不能修改映射本身(例如不能插入或删除键)。
  3. 支持 std::mapstd::unordered_map
  4. 使用 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();
};

解释:

  1. 类型约束
    • T 必须有 iteratorkey_typemapped_type 类型。
  2. 操作约束
    • t.find(key) 必须返回迭代器。
    • 可以与 t.end() 比较,确保可以安全判断键是否存在。
  3. 不允许使用 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
}

核心理解

  1. 概念约束了类型和操作
    • 使用 requires 确保传入类型具备必要成员和操作。
    • 这是一种 精细化类型接口约束,类似静态接口(Static Interface)。
  2. 可修改值,不可修改容器结构
    • 通过限制不使用 operator[],只允许 find + iterator->second
  3. 兼容性
    • 支持 std::mapstd::unordered_map 等标准关联容器。
  4. 安全与编译期检查
    • 如果传入不符合 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;

问题:

  1. 手动管理内存
    • new + delete 不对称,容易内存泄漏。
  2. 表达式构造笨重
    • 嵌套 new 太长,阅读困难。
  3. 用户暴露实现细节
    • 用户必须知道底层用裸指针,增加认知负担。

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;

改进:

  1. 内存自动管理,无需手动 delete
  2. 避免裸指针泄漏。

问题仍然存在:

  • 语法仍然复杂,嵌套 make_unique 太长。
  • 用户仍需要关心智能指针细节(类型、移动语义)。

3⃣ 隐藏智能指针

目标代码:

auto e = Sum(Exp(Number(3), Number(2)), Number(-1));
cout << e << " = " << e.eval() << endl;

改进点:

  1. 用户完全不关心智能指针。
  2. 内部实现可自由选择存储策略(unique_ptr 或其他)。
  3. 构造语法简洁明了,接近数学表达式。

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());
    }    
};

核心设计思路:

  1. 私有智能指针成员
    • 用户不可访问,隐藏了内存管理细节。
  2. 模板构造函数
    • 可以接受任意 Expression 类型(如 Number, Sum, Exp)。
    • 内部自动转换为 unique_ptr
  3. 多态 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

优点总结:

  1. 隐藏实现细节
    • 用户不需要知道内部使用了 unique_ptr
  2. 易于维护和修改
    • 将来可以换成 shared_ptr 或其他存储方式,无需修改用户代码。
  3. 语法清晰
    • 接近数学表达式书写方式。
  4. 内存安全
    • 自动管理,无需 delete

7⃣ 限制与注意事项

  • 目前 不能直接传递已有对象的左值,例如:
    auto e2 = Sum(e, Number(3)); // 编译失败
    
    因为模板构造函数使用了 std::move,要求传入右值。
  • 可以进一步改进,添加 拷贝/移动构造 支持,使左值也可传递。
    小结
  • 本设计思想体现了 “最小接口暴露 + 隐藏实现细节” 的理念。
  • 利用了 模板 + unique_ptr + 多态 实现 安全、简洁、可扩展的表达式树

1⃣ 隐藏继承层次细节(Hiding Inheritance Hierarchies Details)

使用的设计模式

  1. State Pattern(状态模式)
    • 核心思想:对象行为根据其内部状态变化。
    • 示例:一个 Employee 无论是 FullTimeEmployeePartTimeEmployeeContractor,对外都是 Employee 类型,具体行为由内部状态决定。
  2. Strategy Pattern(策略模式)
    • 核心思想:算法或行为可以动态切换。
    • 示例:PathFinder 可使用 BFS 或 DFS 算法,外部调用者只关心 PathFinder,不必关心使用哪种算法。
  3. 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};
}
  • 优点
    1. 隐藏具体类型,调用者不关心容器是 vector 还是 array
    2. 保留必要特性:保证返回类型是 随机访问范围 (random_access_range) 并且元素类型是 int
    3. 接口清晰:使用概念表达“我需要什么性质的返回值”,而不是具体类型。
  • 缺点
    • 需要 C++20 概念支持。
    • 对于简单函数可能略显复杂。

3⃣ 总结对比


方法 隐藏信息 类型限制 灵活性
vector<int> 暴露具体类型 明确 不灵活,容器固定
auto 隐藏类型 无法限制特性 灵活,可更换容器
concept auto 隐藏类型 保证性质(如随机访问) 灵活且安全

核心思想

  • 使用 auto + 概念 可以在 隐藏实现细节 的同时 保留静态约束,兼顾安全与灵活性。
  • 适合公共接口设计,尤其在库开发中。

1⃣ 使用概念隐藏返回值类型(Conveying less information on return values)

核心点

  1. 概念作为返回值类型
    • C++20 支持:
      random_access_range_of<int> auto foo() { ... }
      
    • 自动类型推导 auto 必须在函数体内实现(header 中)。
    • 编译器不会检查使用者的操作是否完全符合概念限制,只保证返回值在概念约束下可用。
  2. 接口隐藏与 Hyrum’s Law
    • 如果暴露太多实现细节,调用者可能依赖这些细节,造成 leaky abstraction
    • Hyrum’s Law: “任何你允许的使用者行为,都会被依赖”,意味着即便你只想隐藏内部实现,用户也可能依赖暴露的细节,导致维护难度增加。
  3. 实现隐藏的方式
    • 概念返回类型(C++20)
      • 优点:类型隐藏,仍可表达静态约束。
      • 缺点:需要头文件内实现,编译器对使用者行为不做检查。
    • 接口 + 虚函数
      • 动态派发隐藏具体类型。
      • 缺点:引入运行时开销。
    • 包装类(Wrapper Class)
      • 可封装复杂类型或行为。
      • 优点:更灵活,仍可隐藏实现细节。
      • 缺点:工作量稍大。
  4. 注意“泄漏的抽象”
    • 典型例子:std::vector<bool> 返回的 proxy 引用。
    • 用户可能依赖返回 proxy 的行为,而不是仅仅使用 bool,破坏了抽象封装。

2⃣ 隐藏辅助函数(Hiding Helper Functions)

问题示例

class Thingy {
public:
    void foo();
    // 注意:不要在调用 foo 之前调用 bar!
    void bar();
};
  • foobar 有调用顺序依赖。
  • 用户可以错误地直接调用 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();
    }
};
  • 优点:
    1. 用户只能调用 foobar(),顺序安全。
    2. 可以传入 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
  • 内部实现细节被完全隐藏,调用者不能误用 foobar

总结

  1. 隐藏返回值类型
    • 用概念、接口或包装类隐藏实现,避免泄露具体类型。
    • 注意 Hyrum’s Law:用户可能依赖暴露的行为。
  2. 隐藏辅助函数
    • 将多个相关函数封装到单个公共接口中。
    • 可使用模板 + 回调函数实现灵活控制,同时保证调用顺序安全。
    • 典型模式:命令封装 + 回调
  3. 设计理念
    • 封装内部实现,让接口保持简单。
    • 避免用户依赖内部实现细节,减少维护风险。
    • 灵活性与安全性的平衡是关键。

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⃣ 特性和优点

  1. 强制调用顺序
    • 用户不能直接调用 bar()
    • bar() 只能通过 foo() 返回的对象调用,保证了 bar() 前必先执行 inner_foo()
  2. 隐藏内部实现
    • inner_foo()bar() 都是私有的,实现细节不暴露给用户。
  3. 可链式调用
    • 通过返回临时对象,可写成:
      t.foo().bar();
      
  4. 灵活性
    • 用户可以选择只调用 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⃣ 总结

这种 “返回临时对象控制访问” 的方法是一种 安全封装辅助函数 的高级技巧:

  1. 让内部实现细节保持私有。
  2. 强制执行调用顺序。
  3. 支持链式调用和可选调用。
  4. 接口对用户更直观、易用,同时内部逻辑安全。
    可以看作是 “临时代理对象模式”(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 优点

  1. 隐藏实现细节
    • 构造函数仅可通过私有令牌访问。
  2. 强制工厂函数使用
    • 确保对象创建逻辑一致。
  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⃣ 小结原则

  1. 封装优先:私有字段、受保护方法、公共接口。
  2. 解耦组件:只暴露必需的行为。
  3. 上下文敏感:不同使用场景可能需要不同可见性。
  4. 保持抽象:抽象提供精确语义,而不是模糊表达。
  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); //  安全创建
  • 隐藏构造函数实现。
  • 强制通过工厂函数创建对象。
  • 避免滥用和接口泄露。
    总结
    隐藏实现细节的目标是 安全、可维护、可复用 的代码。
  • 私有字段和保护成员只是基础。
  • 抽象、工厂、概念和模块是高级工具。
  • 保持接口最小化,暴露必要行为,才能有效控制复杂性。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐