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)。
  • 静态反射。

不受待见(?)的方案有:依赖注入(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的定制点都难免让实现藏得更深,也许该从语法上提供直接改进。

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

这些美好设想现在还看不到弊端,但是有更多的可行手段必然是利好开发者。

结束语

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

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

References

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