C++ 中的 Pipeable 编程

458 阅读6分钟

Pipeable

缘起

Pipeable 或许是相当有争议的一种 C++ 编程方式。

在 Boost 中有 pipeable : pipable — Boost.HigherOrderFunctions 0.6 documentation - master 。它是 hof 库 的一个部分:

 #include <boost/hof.hpp>
 #include <cassert>
 using namespace boost::hof;
 ​
 struct sum
 {
     template<class T, class U>
     T operator()(T x, U y) const
     {
         return x+y;
     }
 };
 ​
 int main() {
     assert(3 == (1 | pipable(sum())(2)));
     assert(3 == pipable(sum())(1, 2));
 }

HOF 库是 Higher-order functions for C++ 的意思,其作者 Paul Fultz II 也是名人。在他的博客中有两篇 Posts 和本文主题相关:

  1. Pipable functions in C++14
  2. Extension methods in C++

同样地,Boost 中也有 Extension methods 与之相对应。

基本机理

我们注意到,Pipeable 编程的起源来自于 OS Shell 中的管道操作,例如:

 cat 1.txt | grep 'best of' | uniq -c | head -10

特别是当 C++ 的运算符 '|'(逻辑或)也能被重载时,一切似乎就变得顺理成章了。

它的 C++ 关键思路是做 '|' 操作符重载以及 '()' 操作符重载:

 template<class F>
 struct pipe_closure : F
 {
     template<class... Xs>
     pipe_closure(Xs&&... xs) : F(std::forward<Xs>(xs)...)
     { }
 };
 ​
 template<class T, class F>
 decltype(auto) operator|(T&& x, const pipe_closure<F>& p)
 {
     return p(std::forward<T>(x));
 }

于是用户类 F 就能够在 pipe_closure 的装饰下具有 '|' 操作的能力(只要 F 也实现了 '()' 操作符重载:

 struct add_one_f
 {
     template<class T>
     auto operator()(T x) const // `T const &x` is better
     {
         return x + 1;
     }
 };

那么 pipeable 串联操作就像这样:

 const pipe_closure<add_one_f> add_one = { };
 int number_3 = 1 | add_one | add_one;
 std::cout << number_3 << std::endl;

要注意 '()' 操作符接受泛型类 T 为入参,这样就能够收到 pip_closure<F>::operator | 转发的 lhs 要件了,也即 "|" 操作符的左手操作数。这样一来,lhs 表达式的结果就被 pipe 到了 rhs 操作数到 operator ()<T>() 中,从而完成了这次管道操作。

略加改进的

显然这个思路还可以进一步美化。

 namespace hicc::pipeable {
 ​
     template<class F>
     struct pipeable {
     private:
         F f;
 ​
     public:
         pipeable(F &&f)
             : f(std::forward<F>(f)) { }
 ​
         template<class... Xs>
         auto operator()(Xs &&...xs) -> decltype(std::bind(f, std::placeholders::_1, std::forward<Xs>(xs)...)) const {
             return std::bind(f, std::placeholders::_1, std::forward<Xs>(xs)...);
         }
     };
 ​
     template<class F>
     pipeable<F> piped(F &&f) { return pipeable<F>{std::forward<F>(f)}; }
 ​
     template<class T, class F>
     auto operator|(T &&x, const F &f) -> decltype(f(std::forward<T>(x))) {
         return f(std::forward<T>(x));
     }
 ​
 } // namespace hicc::pipeable

然后,能够有更雅致的呈现:

 void test_piped() {
     using namespace hicc::pipeable;
 ​
     {
         auto add = piped([](int x, int y) { return x + y; });
         auto mul = piped([](int x, int y) { return x * y; });
         int y = 5 | add(2) | mul(5) | add(1);
         hicc_print("    y = %d", y);
 ​
         int y2 = 5 | add(2) | piped([](int x, int y) { return x * y; })(5) | piped([](int x) { return x + 1; })();
         // Output: 36
         hicc_print("    y2 = %d", y2);
     }
 }

上面的 pipeable class 以及 piped(...) 源于 Functional pipeline in C++11 - Victor Laskin's Blog,他的实现更 meaningful,所以我们更喜欢一些。

ACK:下图来自他的博客

c++11 pipeline

Paul Fultz II 的动机更优先,只是我从来不会真正使用 boost,它太庞大了,太完整了,根本不适合在工程中使用。我更喜欢相对轻量级的,但却又相对完整的。

如果你对 Paul Fultz II 的实现感兴趣,除了 boost::hof::pipeable 之外,他还制作了开源的 Linq: pfultz2/Linq: Linq for list comprehension in C++ 。这是 C# Linq 技术的 C++ 实现,很 amazing,也很疯狂。

后继

以下是延伸阅读时间。

ranges

当然,也需要提到的是 std::ranges 范围库,它是 C++20 带来的改变之一,不过目前 C++20 规范也没发布多久,工程应用并不现实。

ranges 也有同名的孵化项目 ericniebler/range-v3: Range library for C++14/17/20, basis for C++20's std::ranges ,如果不想立即启用 c++20,那么可以使用这个项目,它需要 clang 3.6+ 或者 gcc 4.9.1,所以旧工程表示无压力。

总的来说,ranges 提供一种 filter 手段,在管道过滤的基础上你可以做筛选、排序、mapreduce 等等操作。

不过 range v3 也是褒贬不一了。

怎么理解范围库?

实际上这个家伙和其它一些术语,例如 protocol,concept 等等同样地莫名其妙、不知所云。

你知道什么是概念吗?iykyk,我特么一真真的中国人还会对概念没概念吗。

不过,C++20 的 std::concept 就是个怪怪的东西。

它们的实际含义要去考究英文词根的词源,所以对于中国人来说很难直观理解。

在这里,我们不去管 ranges 词源问题,而是借助于 std::ranges::view 来理解它。范围库换个人话来说,就是对一个集合做一个截面,获得一个可观察的视图(view),然后对该 view 进行各种可能的操作,可以倍乘、加一,也可以计算均值、做其他聚集操作,还可以排序,更可以 map reduce。

能做什么的问题,不是 ranges 的问题,而是你准备如何在 ranges 提供的 view 上做操作的问题。所以想要运用好 ranges 库,你应该对函数式编程(functional programming)有足够的理解。对于 C# Linq 技术,RxJava 有理解的朋友能够很容易理解 std::ranges 的核心内涵。

使用

至于使用 ranges,参考它们提供的例子:

 #include <iostream>
 #include <string>
 #include <vector>
 ​
 #include <range/v3/view/filter.hpp>
 #include <range/v3/view/transform.hpp>
 using std::cout;
 ​
 int main()
 {
     std::vector<int> const vi{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
     using namespace ranges;
     auto rng = vi | views::filter([](int i) { return i % 2 == 0; }) |
                views::transform([](int i) { return std::to_string(i); });
     // prints: [2,4,6,8,10]
     cout << rng << '\n';
 }

很难说这是不是进步。

别人家的孩子

康康 Kotlin 的:

 val animals = listOf("raccoon", "reindeer", "cow", "camel", "giraffe", "goat")
 ​
 // grouping by first char and collect only max of contains vowels
 val compareByVowelCount = compareBy { s: String -> s.count { it in "aeiou" } }
 ​
 val maxVowels = animals.groupingBy { it.first() }.reduce { _, a, b -> maxOf(a, b, compareByVowelCount) }
 ​
 println(maxVowels) // {r=reindeer, c=camel, g=giraffe}

如果是 RxJava 的 Kotlin 更好看:

 Observable.just("Apple", "Orange", "Banana")
 .subscribe(
   { value -> println("Received: $value") }, // onNext
   { error -> println("Error: $error") },    // onError
   { println("Completed!") }                 // onComplete
 )

要不是 Kotlin 总是带着 JVM,我就完整彻底地慕了。

当然 Rx 还有 c++ 版本 RxCpp,用起来是这个味道:

 #include "rxcpp/rx.hpp"
 namespace Rx {
 using namespace rxcpp;
 using namespace rxcpp::sources;
 using namespace rxcpp::operators;
 using namespace rxcpp::util;
 }
 using namespace Rx;
 ​
 #include <regex>
 #include <random>
 using namespace std;
 using namespace std::chrono;
 ​
 int main()
 {
     random_device rd;   // non-deterministic generator
     mt19937 gen(rd());
     uniform_int_distribution<> dist(4, 18);
 ​
     // for testing purposes, produce byte stream that from lines of text
     auto bytes = range(0, 10) |
         flat_map([&](int i){
             auto body = from((uint8_t)('A' + i)) |
                 repeat(dist(gen)) |
                 as_dynamic();
             auto delim = from((uint8_t)'\r');
             return from(body, delim) | concat();
         }) |
         window(17) |
         flat_map([](observable<uint8_t> w){
             return w |
                 reduce(
                     vector<uint8_t>(),
                     [](vector<uint8_t> v, uint8_t b){
                         v.push_back(b);
                         return v;
                     }) |
                 as_dynamic();
         }) |
         tap([](vector<uint8_t>& v){
             // print input packet of bytes
             copy(v.begin(), v.end(), ostream_iterator<long>(cout, " "));
             cout << endl;
         });
 ​
     // ...
     // full codes at: https://github.com/ReactiveX/RxCpp
     return 0;
 }

只能说什么,坏就坏在 [...](...){ ... } 这样的 lambda 函数体太离谱了,相当不好看。它和 kotlin 的 lambda 一比较的话就只能退散了。

其实 C++11 以来的各种改变一直如此,造就了大量奇奇怪怪不符合直觉的东西。若非也有新造一些好东西,加上历史沿革,恐怕它也死了吧。

关于我提到的争议

首先一方面,C++ 的匿名函数形状太糟糕。请参考 C++0x lambdas suck,C++ lambda ugly 等等。

另一方面,我们不得不看到,这也是历史的原因,不提 C++ 的函数式原本有它自己的风格,而 ranges 之类的东西标准来的太迟,太多各种各样的实现造成了混乱,而标准嘛、总是姗姗来迟。

有了 C# 闭包,Java 8 闭包,Kotlin 闭包的珠玉在前,先入为主之下,C++ 的这一战役是胜不了了。

关于 Extension Method

这个概念,是协议化编程(Protocol-oriented Progamming)的一部分。

Swift

在 Swift 语言中,你可以通过 Protocol 的方式对任意一个类进行扩充(Extensions)。它表现的有些像这样:

 let pythons = ["Eric", "Graham", "John", "Michael", "Terry", "Terry"]
 let beatles = Set(["John", "Paul", "George", "Ringo"])
 ​
 extension Collection {
     func summarize() {
         print("There are (count) of us:")
 ​
         for name in self {
             print(name)
         }
     }
 }
 ​
 // Both Array and Set will now have that method, so we can try it out
 pythons.summarize()
 beatles.summarize()

另一个例子,在 String 类上增加一个 reverse() 方法(注:String 有原生方法 reversed()):

 import Foundation
 ​
 extension String {
     func reverse() -> String {
         var word = [Character]()
         for char in self {
             word.insert(char, at: 0)
         }
         return String(word)
     }
 }
 ​
 var bobobo = "reversing"
 print(bobobo.reverse())
 // gnisrever

上面的例子们都没有利用 Protocol 进行约束,所以只展示了对已有的类做 Extension 的能力。你可以对符合特定 protocol 约束条件的泛型类进行 extension,从而让这组泛型类额外地具备一个新的方法。这些内容太庞大了,所以不再在这里继续展开了。

Kotlin

Kotlin 提供了 Extension Function 的手段,这对 Java 可以算是颠覆性的创新设计。

这次的例子稍微变一变:

 fun String.reverseCaseOfString(): String {
     val inputCharArr = toCharArray()
     var output = ""
     for (i in 0 until inputCharArr.size) {
         output += if (inputCharArr[i].isUpperCase()) {
             inputCharArr[i].toLowerCase()
         } else {
             inputCharArr[i].toUpperCase()
         }
     }
     return output
 }

这就为 String 类增加了 reverseCaseOfString() 方法,用起来和 String.length() 没有区别:

 print("CaseSensitive".reverseCaseOfString())

做手机端开发的朋友对 Swift 和 Kotlin 的这个能力应该是很熟悉的。

在 C++ 中

我已经研究、梦想了很久了(数年之久,跨了十年了),想要在 C++ 中能够做同样的事情。不过即使到 c++23 这也仍旧是不可能滴。

至于创作一个等效的 C++ 类库,屡败屡战之下,结论是仍旧不可能。

不那么完美地通过 c++ 来达到扩展已有类功能,可以通过 Decorator Pattern,这是最规范的方案,但形状不好、累赘太多:

 #include <iostream>
 #include <string>
 ​
 struct Shape {
   virtual ~Shape() = default;
 ​
   virtual std::string GetName() const = 0;
 };
 ​
 struct Circle : Shape {
   void Resize(float factor) { radius *= factor; }
 ​
   std::string GetName() const override {
     return std::string("A circle of radius ") + std::to_string(radius);
   }
 ​
   float radius = 10.0f;
 };
 ​
 struct ColoredShape : Shape {
   ColoredShape(const std::string& color, Shape* shape)
       : color(color), shape(shape) {}
 ​
   std::string GetName() const override {
     return shape->GetName() + " which is colored " + color;
   }
 ​
   std::string color;
   Shape* shape;
 };
 ​
 int main() {
   Circle circle;
   ColoredShape colored_shape("red", &circle);
   std::cout << colored_shape.GetName() << std::endl;
 }

使用装饰模式,既可以装饰已有方法的实现,也可以新增方法(CIrcle.Resize()),但你需要显示地构造到装饰器类才行(ColoredShape colored_shape("red", &circle))。改进的方法是利用模板类进行 mixin,但那也改善的有限。

小结

以上,个人观点,你随便看看就好——Dont come for me pls。

Retried

🔚