第9章:代理对象与惰性求值

对齐第一版第9章 Proxy Objects and Lazy Evaluation:eager/lazy边界、string proxy延迟连接、DistProxy推迟sqrt,以及operator overloading构造pipe和infix语法。

学习目标

  • 能比较eager evaluation与lazy evaluation的工作量、物化时机、重复求值和异常边界
  • 能设计安全的string proxy与DistProxy,并推导operand lifetime和隐式转换发生的位置
  • 能分析operator overloading构造的pipe operator与infix operator,判断语法收益是否覆盖隐藏成本

机制总览

第9章:代理对象与惰性求值:机制路径

  1. 1

    从“结果何时必须成为真实对象”开始

    eager evaluation在表达式出现时立即计算并产生结果;lazy evaluation先保存operation与operands,只有consumer真正请求value时才计算。惰性可以跳过未使用结果、融合多个operation、减少temporary和expensive work,但也会…

  2. 2

    proxy objects保存“如何得到值”

    proxy objects表现得像目标value或reference,却内部保存operation、operand handles或compressed state。标准库中 vector ::reference 是bit proxy,expression template node也是value …

  3. 3

    concatenated strings与temporar…

    连续string operator+ 的直觉实现会为每个中间结果取得storage并复制prefix;但现代library可利用rvalue overload、move、capacity和small-string optimization,不能声称每个加号必然allocate。可靠baseline是…

先按顺序建立机制,再进入实验切换阶段并检查失效证据。

章级决策实验

第9章:代理对象与惰性求值:机制与证据

切换《第9章:代理对象与惰性求值》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。

选择推理阶段

当前阶段 · 从“结果何时必须成为真实对象”开始

eager evaluation在表达式出现时立即计算并产生结果;lazy evaluation先保存operation与operands,只有consumer真正请求value时才计算。惰性可以跳过未使用结果、融合多个operation、减少temporary和expensive work,但也会…

可核验证据

保留可复现基准、输入规模和编译参数,用采样剖析与硬件计数器核对「从“结果何时必须成为真实对象”开始」前后的时间和资源变化。

学完《第9章:代理对象与惰性求值》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。

失效—证据矩阵

第9章:代理对象与惰性求值:失效与核验

从“结果何时必须成为真实对象”开始

典型失效

若脱离基线与成本模型讨论「从“结果何时必须成为真实对象”开始」,局部优化可能只是在移动开销,甚至让缓存、分配或同步瓶颈更严重。

核验证据

保留可复现基准、输入规模和编译参数,用采样剖析与硬件计数器核对「从“结果何时必须成为真实对象”开始」前后的时间和资源变化。

proxy objects保存“如何得到值”

典型失效

若脱离基线与成本模型讨论「proxy objects保存“如何得到值”」,局部优化可能只是在移动开销,甚至让缓存、分配或同步瓶颈更严重。

核验证据

保留可复现基准、输入规模和编译参数,用采样剖析与硬件计数器核对「proxy objects保存“如何得到值”」前后的时间和资源变化。

concatenated strings与temporar…

典型失效

若脱离基线与成本模型讨论「concatenated strings与temporar…」,局部优化可能只是在移动开销,甚至让缓存、分配或同步瓶颈更严重。

核验证据

保留可复现基准、输入规模和编译参数,用采样剖析与硬件计数器核对「concatenated strings与temporar…」前后的时间和资源变化。

每个判断都必须能落到观测、测试或产物,不能只凭代码表面推测。

从“结果何时必须成为真实对象”开始

eager evaluation在表达式出现时立即计算并产生结果;lazy evaluation先保存operation与operands,只有consumer真正请求value时才计算。惰性可以跳过未使用结果、融合多个operation、减少temporary和expensive work,但也会把成本、异常与lifetime推迟到看似无害的dereference或conversion处。

先预测consumer需要什么:完整value、一次comparison、前几个元素、还是可能完全不用。再标出materialization boundary、operand ownership和evaluation count。若结果一定马上使用且计算便宜,eager value通常更简单;若只需部分信息或中间结果昂贵,proxy才可能有意义。

lazy不等于cached。普通proxy每次conversion都可能重新计算;memoization才保存第一次结果,并引入mutable state、同步、memory和invalidation问题。API必须说明是single evaluation、repeatable expression还是cache-once value,不能让调用者猜。

proxy objects保存“如何得到值”

proxy objects表现得像目标value或reference,却内部保存operation、operand handles或compressed state。标准库中vector<bool>::reference是bit proxy,expression template node也是value proxy。proxy可在operator chain中避免中间物化,但它的reference category、copy behavior与addressability可能不同于真正T&

generic code若写auto& ref = *it,可能拒绝返回temporary proxy的iterator;若写auto value = *it,又可能复制proxy而不是materialized value。应从expression type和documented concept推理,不要假定dereference永远返回T&。proxy的operator应尽量small、inline且明确value category。

concatenated strings与temporary成本

连续string operator+的直觉实现会为每个中间结果取得storage并复制prefix;但现代library可利用rvalue overload、move、capacity和small-string optimization,不能声称每个加号必然allocate。可靠baseline是预计算总长度、一次reserve并依次append,再与目标实现实测。

std::string concatenate(std::string_view a,
                        std::string_view b,
                        std::string_view c) {
    std::string result;
    result.reserve(a.size() + b.size() + c.size());
    result.append(a).append(b).append(c);
    return result;
}

concatenated strings的lazy方案让每个+返回string proxy/expression node,只记录operands;最终转换到std::string时遍历expression tree,先求total size,再一次allocate并copy leaves。若最终只比较length或写入stream,consumer还可直接遍历leaves,不必产生contiguous string。

string proxy的ownership与lifetime

最简单proxy可保存string_view,但只安全借用lifetime覆盖evaluation的字符串。若expression被保存,而operand来自temporary、local buffer或随后改变的string,proxy可能悬空或观察到意外内容。完善实现通常按value category区分:lvalue存borrow/reference wrapper,rvalue按值move进入node;所有nodes本身再按值组成expression tree。

struct StringProxy {
    std::string_view left;
    std::string_view right;
 
    std::string materialize() const {
        std::string value;
        value.reserve(left.size() + right.size());
        value.append(left).append(right);
        return value;
    }
};
 
std::string first = "render";
std::string second = " graph";
auto expression = StringProxy{first, second}; // both owners outlive expression
auto value = expression.materialize();

这个示例刻意只展示borrowed lvalues,不支持temporary。生产接口若接受std::string&&却仍保存view就是bug。另一种保守设计是node总按值own strings,lifetime简单但可能复制;或使用shared ownership,代价是allocation/reference count。最优representation取决于operand size、expression存活时间和materialization频率。

expression tree也可能增长很深,导致type explosion、compile time和巨大closure。可用small flat segment list、rope或threshold materialization控制深度。若source string在materialize前被修改,borrow semantics是否允许观察新值必须写清;value semantics则应capture旧值。

postponing sqrt:DistProxy案例

二维距离通常返回sqrt(dx*dx + dy*dy)。排序、比较阈值或找nearest时,sqrt是monotonic transformation:比较非负距离可直接比较squared distance,只有最终显示真实长度时才开方。DistProxy可以保存squared value,让comparison使用它,conversion/materialize才调用sqrt。

class DistProxy {
public:
    explicit DistProxy(double squared) : squared_(squared) {}
 
    double squared() const { return squared_; }
    double value() const { return std::sqrt(squared_); }
 
    friend bool operator<(DistProxy a, DistProxy b) {
        return a.squared_ < b.squared_;
    }
 
private:
    double squared_;
};
 
DistProxy distance(Point a, Point b) {
    const auto dx = a.x - b.x;
    const auto dy = a.y - b.y;
    return DistProxy{dx * dx + dy * dy};
}

显式value()比隐式operator double()更容易看见昂贵边界;若为便利提供conversion,必须避免意外触发多次sqrt。threshold比较应把threshold平方,但要考虑overflow、NaN、negative input和unit。整数坐标的dx*dx可能在转换double前先整数overflow,应先扩展类型。

postponing sqrt只有在consumer不总需要真实distance时有收益。若每个proxy立刻转double,额外wrapper不减少工作;若compiler本就能消除或vectorize,差异也可能很小。benchmark应包含comparison-only、mixed与always-materialize三种workload,并统计sqrt call count。

operator overloading构造表达式语言

operator overloading可让proxy chain接近domain notation。pipe operator常重载operator|,把左侧value交给右侧adaptor/function;每一段可返回新proxy,实现filter/transform/materialize。它受C++既有precedence和associativity约束,不能定义新operator或改变求值规则。

template <class Function>
struct Pipe {
    Function function;
};
 
template <class Value, class Function>
decltype(auto) operator|(Value&& value, const Pipe<Function>& pipe) {
    return pipe.function(std::forward<Value>(value));
}
 
auto doubled = Pipe{[](int value) { return value * 2; }};
auto result = 21 | doubled;

infix operator技巧常借两个可重载operator拼成lhs <op> rhs一类语法:第一个operator返回包含lhs和operation的中间proxy,第二个接收rhs并计算。这不是自定义真正的新运算符,precedence由<>等原符号决定,短路行为也不会自动出现。为了“像自然语言”引入模糊precedence和临时lifetime,通常不如named function。

operator API必须保持expectation:+通常不修改operands并像value operation,|通常表示pipeline;重载,&&等会让读者误判sequencing/short-circuit。隐藏allocation、I/O、lock或远程调用尤其危险。proxy DSL适合边界清晰、团队熟悉且有profile收益的domain,不是减少字符数的游戏。

eager与lazy的决策协议

选择lazy前回答六个问题:结果有多大/多贵,多少结果会被跳过,能否fusion,operand由谁own,expression会evaluate几次,异常和debug stack应出现在哪里。再比较eager baseline、lazy proxy和手工fused implementation的allocation、bytes copied、operation count、compile time和binary size。

若lazy object跨thread,还要处理borrowed data同步、memoization race和exception transport。若proxy出现在public ABI,implementation type explosion会泄漏给caller并增加rebuild。可在module boundary materialize value,把lazy限定在hot expression内部。

第9章实验协议

  1. 比较a+b+c+d、reserve+append与string proxy,按长度分布记录allocation和bytes copied。
  2. 让string proxy分别借用lvalue、接收temporary、跨scope保存,用ASan验证lifetime设计。
  3. 对proxy重复materialize两次,确认是重复计算还是明确memoization。
  4. 用DistProxy执行nearest search,统计comparison与sqrt call count,再比较直接distance。
  5. 覆盖integer overflow、NaN和threshold-squared,验证distance ordering contract。
  6. 为pipe chain记录每段return type、ownership与materialization boundary。
  7. 给infix语法混合算术/比较运算符,加括号前后检查precedence是否符合阅读预期。
  8. 同时记录runtime、allocation、compile time、binary size和diagnostic complexity。

小结

  • eager立即产生value,lazy保存expression并在观察边界计算;lazy不自动等于cache
  • proxy object可避免temporary或提供reference-like访问,但可能改变address、copy与conversion语义
  • concatenated strings可通过string proxy一次计算长度和物化,operand lifetime是首要约束
  • DistProxy比较平方距离并postpone sqrt,只有部分consumer需要真实距离时才节省工作
  • 隐式conversion可能隐藏重复materialization,显式value接口更易审计
  • pipe operator适合从左到右composition;infix operator只是已有符号加中间proxy
  • operator overloading不能改变precedence或凭空获得short-circuit
  • lazy方案必须同时测runtime、allocation、compile time、binary和维护成本

名词解释

本章出现的专业名词,用大白话再讲一遍。

eager evaluation

表达式出现时立即计算并生成独立结果。

lazy evaluation

保存计算描述并在观察结果时执行。

proxy object

代替真实值或引用、在访问时执行操作的对象。

string proxy

延迟字符串连接并在最终物化时统一复制的代理。

DistProxy

保存平方距离并按需执行sqrt的距离代理。

pipe operator

用竖线重载组合从左到右数据处理的语法。

infix operator

用已有运算符和中间proxy模拟命名二元操作。

练习

  1. 问题 1:为什么保存两个string_view的StringProxy不能安全接受temporary string? 设计lvalue/rvalue策略。
  1. 问题 2:nearest-point只需要返回最近对象,DistProxy怎样减少sqrt? 给出正确性和性能证据。
  1. 问题 3:一个pipe/infix DSL何时比named functions更危险? 审计precedence、隐藏工作和lifetime。

讨论

评论区加载中…