Home C++ 的定制点设计
Post
Cancel

C++ 的定制点设计

定制点(customization point)在特定领域里也是老生常谈的话题了,本文快速总结相关的设计方案。

什么是定制点

作为库开发者的大佬们想必都会思考如何为库使用者提供一种定制能力,这就是广义上的定制点。

另外也可以通过 C++ 语言标准了解定制点。它的定义是较为狭义的(标准库视角),但也只要求是写在 std 命名空间外部的某种重载方式就行了。

Other than in namespace std or in a namespace within namespace std, a program may provide an overload for any library function template designated as a customization point, provided that (a) the overload’s declaration depends on at least one user-defined type and (b) the overload meets the standard library requirements for the customization point.

本文不局限于标准库,探讨广义的 C++ 定制点。

有哪些定制点

目前 C++ 的主流方案有:

  • 类继承。
  • 类模板特化。
  • 实参依赖查找(ADL)。
  • 定制点对象(CPO)。
  • tag_invoke

未来可能提供的方案有:

  • 可定制函数(customizable functions)。
  • 静态反射。
  • proxy。

不受待见 (?) 的方案有:依赖注入(DI)。

类继承

类继承的介绍就无需多言了,示例如下:

class Loggable {
public:
    virtual void dump() = 0;
};

class Meme: public Loggable {
public:
    void dump() override { /*...*/ }
};

struct Logger {
    void log(Loggable &loggable) {
        loggable.dump();
    }
    // ...
};

virtual 是语言特性直接给出的定制点支持,能得到的好处大家都懂,但缺陷还是值得一提。

有一种观点认为类继承是侵入式的设计:侵入指的是更改了类结构,比如引入了额外的子对象。而类继承显然引入了不必要的基类。其实引入不算问题,但是一个不能修改的类就无法完成定制确实是问题。

另一个问题是带来了可能不必要的额外运行时成本。虽说运行时多态的场合下是没有太多的可选方案,但在编译期确认的情况下,成本再低也是成本。因此后续介绍的方案多为静态多态方案。

最后还有一点是类型系统的扩展问题。类继承只支持到类类型(class type,也就是口头的 class 或者 struct),并没有覆盖到数组类型、指针类型和基本类型等更多类型的定制。

实际应用:随地都是。

类模板特化

如字面意思,定制点通过类模板的特化来实现。示例如下:

// 用户自定义类型
struct point {
    double x, y;
};

// 库提供的定制点
template <>
struct fmt::formatter<point> : nested_formatter<double> {
    auto format(point p, format_context& ctx) const {
        return write_padded(ctx, [=](auto out) {
            return format_to(out, "({}, {})", nested(p.x), nested(p.y));
        });
    }
};

int main() {
    fmt::print("[{:>20.2f}]", point{1, 2});
}

这种实现是完全非侵入式的,对类型系统有完善的支持且无额外运行时成本。

但也有问题,那就是特化出来的模板必须要全部实现,而不能只实现其中一个函数。也就是说这种定制点只适用于少量的定制。也可参考示例中通过静态的继承提供较为通用的非定制函数来缓解问题。

NOTE: 不建议使用特化的函数模板来完成定制,因为不支持偏特化。这也是为什么 std::hash 使用类模板的原因。

实际应用:{fmt} library

实参依赖查找

实参依赖查找(argument-dependent lookup),又称 ADL 或 Koenig 查找。是 C++ 语言标准中应用于无限定的函数名进行查找的规则。

ADL 的细节很多,简单点可理解为实参所在的命名空间也被加入到当前的函数重载决议的候选集中。

最简单的示例如下:

#include <iostream>

int main() {
    endl(std::cout);
}

ADL 检验 std 命名空间,因为 endl 的实参在 std 中,并找到了 std::endl 且唯一匹配。所以函数确认为 std::endl。(你也可以在 C++ Insights 查看该示例的 ADL 结果。)

由此产生了隐藏友元惯用法(hidden friend idiom)搭配 ADL 作为定制点的方案,示例如下:

#include <iostream>

namespace Foo {
    class Bar {
        // A hidden (private) friend!
        friend void swap(Bar& a, Bar& b) {
            std::cout << "I am here" << std::endl;
        }
    };
} // Foo

int main() {
    Foo::Bar a, b;
    int x, y;
    using std::swap;
    swap(a, b);      // Foo::swap is called.
    swap(x, y);      // std::swap is called.
}

NOTE: 使用隐藏友元可以避免参与正常的名字查找过程,它只能应用于 ADL 过程,有更强的封装性。

This function is to be found via argument-dependent lookup only.

很显然它可以按需定制,没有类模板特化的先天缺陷。并且放弃使用友元的话(定制点函数写到类外即可),也可以做到非侵入式的定制。

但这种方案存在天坑:难以正确的使用(必须两阶段,先 using 再非限定名调用),难以检错(任何一步错了,也有可能编译成功,但是调用了哪个版本就难说了);更离谱的是,调用正确但实现写错了是完全不知情(如果用户没有自行提供 concept,写成 swap-by-value编译器也不会吭声)。

连基本的保障都做不到,简直是犯了定制点的大忌。

实际应用:???

定制点对象

定制点对象(customization point object),又称 CPO。CPO 在核心语言中被定义为一种可与程序定义类型进行特定语义交互的字面类型函数对象。所谓特定语义,就认为是能当函数来调用就好了。

这种设计的存在就是为了弥补 ADL 定制点的缺陷。原理是基于 C++ 类型系统中「函数非对象」的规则,将定制点函数替换为定制点函数对象,从而隐式关闭 ADL(或者说是把 ADL 隐藏到库内部完成)。

NOTE: 很显然,函数对象不是函数。这也没啥好解释的,更多细节请自行翻阅。

一个迭代器 CPO 的示例如下,可以关注 library::begin() 的行为:

#include <type_traits>
#include <vector>
#include <iostream>

namespace library {

    // Best practice: provide a separate namespace for each CP class.
    namespace begin_cpo_detail {
        struct Begin_fn {
            template <typename /*👈concept is better*/ Container>
            decltype(auto) operator()(Container &&container) const {
                // Internal ADL here!
                return begin(std::forward<Container>(container));
            }
        };

        // Generic default begin function template(s).
        // TODO: array types...
        template <typename T>
        decltype(auto) begin(T &&obj) /*noexcept(noexcept(...))*/ {
            return std::forward<T>(obj).begin();
        }
    }

    // All CPOs should be wrapped into this inline namespace.
    inline namespace _cpo {
#if __cplusplus >= 201703L
        // A customization point OBJECT.
        inline constexpr begin_cpo_detail::Begin_fn begin {};
#else // __cplusplus >= 201703L
        // Workaround for C++14.
        template <typename Cpo>
        constexpr Cpo _static_const {};

        namespace {
            // A customization point "OBJECT".
            constexpr auto &begin = _static_const<begin_cpo_detail::Begin_fn>;
        }
#endif
    } // _cpo
} // library


namespace end_user {

template <typename T>
class Vector {
public:
    Vector(std::initializer_list<T> il): _real_vector(il) {}
    // User-defined default begin() function.
    // Called by std::begin().
    auto begin() { return _real_vector.begin(); }

private:
    std::vector<T> _real_vector;

    // User-defined customized begin() function.
    // A hooked begin-iterator (with hidden friend again).
    friend auto begin(Vector<T> &vec) {
        using Hook = std::vector<std::string>;
        static Hook strings {"hook", "jintianxiaomidaobilema"};
        return strings.begin();
    }
};

} // end_user


int main() {
    end_user::Vector<int> f { 1, 2, 3 };
    auto iter1 = f.begin();
    // [1]
    std::cout << *iter1 << std::endl;

    ::puts("===============");

    // STL-begin.
    auto iter2 = std::begin(f);
    // [1]
    std::cout << *iter2 << std::endl;

    ::puts("===============");

    // ADL enabled, STL-bypass-begin.
    auto iter3 = begin(f);
    // [hook]
    std::cout << *iter3 << std::endl;

    ::puts("===============");

    // **CPO is here.**
    // ADL disabled, since library::begin is an object.
    //
    // With library:: prefix, not end_user::,
    // `library` can detect user-defined hooks automatically.
    auto iter4 = library::begin(f);
    // [hook]
    std::cout << *iter4 << std::endl;
}

CPO 实现使得通过显式的<库命名空间>::限定就能检测是否有用户定制实现。如果存在,则优先使用用户定制实现,否则再使用库内置实现。并不会存在任何歧义。显然也没有了最大的天坑问题,库现在可以主动内置 concept 避免错误。

但是这种方案在 C++14 版本是难以实现的,因为缺少 inline constexpr 变量的语法支持,所以还要通过引用多绕一层以满足 ODR 的要求。更具体地说就是模板本身可以 ODR,然后通过匿名 namespace 的内部链接为每一个翻译单元准备一个引用,它们都指涉相同的 CPO 对象,由于引用具有相同的地址,因此符合 ODR 要求。不过这再怎么麻烦也都是过去的事情了。

NOTE0: 为什么示例中 CPO(begin 对象)需要被包裹在 inline namespace?这是前人踩坑的经验……如果去掉这个 inline namespace,在同一个 library 命名空间可能有另一个类使用了 begin 同名的隐藏友元函数,由于 ADL 候选了 library 命名空间的原因,隐藏友元仍可被查找,此时编译器认为存在歧义:begin 既是对象,也是函数。但是语言标准保证了使用 inline namespace 后不会产生歧义。

NOTE1: 从 C++20 开始,lambda 也可以直接作为 CPO 使用。

NOTE2: 还有一个同样禁用 ADL 的玩家是 niebloid。目前 niebloid(的实现)就是等同于 CPO,但是它的定义更加宽泛,未来如果有更新的标准支持显式禁用 ADL,它还可能是一个函数。

The function-like entities described on this page are niebloids…In practice, they may be implemented as function objects, or with special compiler extensions.

实际应用:Range library

tag_invoke

前面的 CPO 看起来已经是个不错的方案,但是探索远没有结束。

tag_invoke 的动机是针对 CPO 定制点过多时改进的措施,以避免对命名空间造成大量污染。因为每增加一个 CPO,那就意味着需要全局预留这个名字(用户使用这个名字将被库检测到并误判为定制点)。

tag_invoke 的做法是全局只预留一个名字 tag_invoke,它是唯一的全局预留 CPO(未来的标准库会提供 std::tag_invoke,但是 tag_invoke 的实现完全可以由库开发者自行完成),其它的定制点重载这个全局定制点再次派发。

tag_invoke 的一个巧妙之处是利用了「CPO 是对象」的性质:既然是对象,那么拿去当参数传递当然没问题。于是有了 tag_invoke(cpo_type cpo, ARGS...) 的定制点设计。

库的开发已经有明确方向了,但剩下用户的定制存在问题:用户怎么知道库使用了什么类型的 CPO?为了避免无聊的萃取,可以在库(或者标准库)层面内置 tag_t 辅助工具。此时对于库用户来说,只需实现 tag_invoke(tag_t<cpo>, ...) 即可得到 cpo 同名定制点。

所以 tag invoke 就是 CPO 加上标签派发得到的产物,这里给出 CPO 章节修改后的 tag_invoke 示例:

#include <type_traits>
#include <vector>
#include <iostream>

// Part of std::tag_invoke.
// Copied and modified from Eric Niebler's gist:
// https://gist.github.com/ericniebler/056f5459cf259da526d9ea2279c386bb
namespace standard {
   namespace detail {
      void tag_invoke();
      struct tag_invoke_t {
         template<typename Tag, typename... Args>
         constexpr decltype(auto) operator() (Tag tag, Args &&... args) const /*noexcept(...)*/ {
            // Internal ADL!
            return tag_invoke(static_cast<Tag &&>(tag), static_cast<Args &&>(args)...);
         }
      };
   }

   inline constexpr detail::tag_invoke_t tag_invoke{};

   template<auto& Tag>
   using tag_t = std::decay_t<decltype(Tag)>;
}

namespace library {
    namespace begin_cpo_detail {
        struct Begin_fn {
            template <typename /*👈concept is better*/ Container>
            decltype(auto) operator()(Container &&container) const {
                // Goto the single reserved CPO (std::tag_invoke).
                return standard::tag_invoke(*this, std::forward<Container>(container));
            }
        };

        // Generic default begin function template(s).
        // TODO: array types...
        template <typename T>
        decltype(auto) tag_invoke(Begin_fn, T &&obj) /*noexcept(noexcept(...))*/ {
            return std::forward<T>(obj).begin();
        }
    }

    inline namespace _cpo {
        inline constexpr begin_cpo_detail::Begin_fn begin {};
    }
} // library


namespace end_user {

template <typename T>
class Vector {
public:
    Vector(std::initializer_list<T> il): _real_vector(il) {}
    // User-defined default begin() function.
    // Called by std::begin().
    auto begin() { return _real_vector.begin(); }

private:
    std::vector<T> _real_vector;

    // User-defined customized begin() function
    // A hooked begin-iterator (with hidden friend again).
    friend auto tag_invoke(standard::tag_t<library::begin>, Vector<T> &vec) {
        using Hook = std::vector<std::string>;
        static Hook strings {"hook", "xiaomijintiandaobila"};
        return strings.begin();
    }
};

} // end_user


int main() {
    end_user::Vector<int> f { 1, 2, 3 };
    auto iter1 = f.begin();
    // [1]
    std::cout << *iter1 << std::endl;

    ::puts("===============");

    // STL-begin.
    auto iter2 = std::begin(f);
    // [1]
    std::cout << *iter2 << std::endl;

    ::puts("===============");

    // No ADL enabled.
    // auto iter3 = begin(f);
    //
    // ::puts("===============");

    // **tag_invoke is here.**
    // ADL disabled, since library::begin is an object.
    //
    // With library:: prefix, not end_user::,
    // `library` can detect user-defined hooks automatically.
    auto iter4 = library::begin(f);
    // [hook]
    std::cout << *iter4 << std::endl;
}

tag_invoke 是目前 ADL 科技的终极版本。

实际应用:NVIDIA stdexec

看提案,战未来?

先别急着 ADL YES。各种基于 ADL 的定制点都难免让实现藏得更深,也许该从语法上提供直接改进。

UPDATE: 更新一下文章,2024/04 发布的 P2300R9 又搞事情把 tag_invoke 否了,具体见 P2855,总结观点就是:The overall highest-priority goal of this proposal is “No ADL, anywhere”。

提案 P2279 提到 2 种新的定制点思路:可定制函数和静态反射。我粗略看了一下,可定制函数指的是通过新的语法去完成 virtual 转换到静态多态的过程(恰好 virtual 不能应用于类外,现在填上这个坑作为新的用途),而不用再考虑复杂的 ADL 问题;静态反射则是另一个思路,通过支持静态反射来完成可定制函数,而不必专门为定制点开发新的语法(其实文本太长没怎么看,很可能是我瞎说的……反正都是思路)。

也先别急着改进语法,微软还尝试了免除 ADL 且不需新增语法糖的改进方式。

提案 P3086 提出了针对运行时多态的非侵入式方案,并且已经提供了可用的 proxy 库实现(要求 C++20 及以上)。

What makes the “proxy” so charming

As a polymorphic programming library, proxy has various highlights, including:

  1. being non-intrusive
  2. allowing lifetime management per object, complementary with smart pointers
  3. high-quality code generation
  4. supporting flexible composition of abstractions
  5. optimized syntax for Customization Point Objects (CPO) and modules
  6. supporting general-purpose static reflection
  7. supporting expert performance tuning
  8. high-quality diagnostics.

proxy: Runtime Polymorphism Made Easier Than Ever – Microsoft C++ Team Blog

现在你可以写出无需继承的动态多态,示例代码如下:

// From: https://github.com/microsoft/proxy?tab=readme-ov-file#quick-start

// Specifications of abstraction
namespace spec {

PRO_DEF_MEMBER_DISPATCH(Draw, void(std::ostream& out));
PRO_DEF_MEMBER_DISPATCH(Area, double() noexcept);
PRO_DEF_FACADE(Drawable, PRO_MAKE_DISPATCH_PACK(Draw, Area));

}  // namespace spec

// Implementation
class Rectangle {
 public:
  void Draw(std::ostream& out) const
      { out << "{Rectangle: width = " << width_ << ", height = " << height_ << "}"; }
  void SetWidth(double width) { width_ = width; }
  void SetHeight(double height) { height_ = height; }
  double Area() const noexcept { return width_ * height_; }

 private:
  double width_;
  double height_;
};

// Client - Consumer
std::string PrintDrawableToString(pro::proxy<spec::Drawable> p) {
  std::stringstream result;
  result << "shape = ";
  p.Draw(result);
  result << ", area = " << p.Area();
  return std::move(result).str();
}

// Client - Producer
pro::proxy<spec::Drawable> CreateRectangleAsDrawable(int width, int height) {
  Rectangle rect;
  rect.SetWidth(width);
  rect.SetHeight(height);
  return pro::make_proxy<spec::Drawable>(rect);
}

int main() {
    auto drawable = CreateRectangleAsDrawable(1, 2);
    auto result = PrintDrawableToString(std::move(drawable));
    std::cout << result << std::endl;
}

结束语

本文快速总结了各种定制点的优缺点及实际应用,有写库需求的时候也可以作为技术选型的参考。

另外,C++20 之后的标准库也渗透着越来越多的定制点(实际应用里面的三巨头将全被收入标准库),其实还藏有很多坑留给大家去踩……这是个漫长的过程,总之先这样吧。

References

Customization Methods: Connecting User and C++ Library Code (CppCon 2023) – YouTube
API Reference – fmt 10.2.0 documentation
Argument-dependent lookup – cppreference
[customization.point.object] – Working Draft
Recommendations for Specifying “Hidden Friends” – ISOCPP
We need a language mechanism for customization points – ISOCPP
A note on namespace __cpo – Arthur O’Dwyer
Suggested Design for Customization Points – ISOCPP
tag_invoke: A general pattern for supporting customisable functions – ISOCPP
Proxy: A Pointer-Semantics-Based Polymorphism Library – ISOCPP
proxy: Runtime Polymorphism Made Easier Than Ever – Microsoft | C++ Team Blog

This post is licensed under CC BY 4.0 by the author.
Contents