对“管道”的进一步理解

起因

近来在看《重构(第二版)》,里面有提到一个重构模式是“以管道取代循环(Replace Loop with Pipeline)”,这让我想起来去年年初的时候学习C++在powershell上的遇到的“管道”,当时还专门写了一篇博客记录了一下:Windows PowerShell的“管道”以及对可执行文件的文件重定向,加上最近又在学习shader,跟渲染管线打交道比较频繁,突然感觉这些东西的思想都是一致的——
把一批操作组合成一个操作序列,然后再把需要处理的一个或一批对象扔到这个操作序列里,在序列的最后获取操作结果。
接下来又联想到C#里的LINQ常用操作,以及很久以前使用过的DOTween插件,无一不是使用了这种管道的编程思想,于是决定把这种编程思想记录一下。

为什么叫"管道"

为什么叫“管道”?想想下面这个图,假设每一个阀门都是一个处理点,那么我们把材料从入口放入,那么所有的材料都会一一通过每一个处理点,每一个处理点进行的操作各不相同,但我们要做的只是在出口等待,管道最后会把最后结果给我们。
在这里插入图片描述

从循环说起

从哪里开始是一个不大不小的问题,想了想,还是从集合开始吧。
“管道”的编程思想,在重构作者的个人网站里有一篇文章有专门论述,他把这种编程方式叫做“集合管道模式(Collection Pipeline Pattern)”,英文好的同学也可以直接查看:Collection Pipeline,之所以会有“集合”这个前缀,跟管道的特点也是离不开的。
我们还是从代码开始。

一. 典型问题

先考虑一个经典的场景:

期末考试学生们进行了考试,每个学生都需要考语数英三门课程,每门课程满分100。
我现在需要统计一下这些学生三门课程平均分超过了60分的人的名单。

以下代码使用C#编写。

按常规思路,我们先对学生建模,创建一个学生类:

    public class Student
    {
        public string name;
        public int maths;
        public int chinese;
        public int english;

        public int Average
        {
            get
            {
                return (maths + chinese + english) / 3;
            }
        }
    }
二. 循环迭代处理

好了,理一理思路,我们的思路可能会是这样:

  1. 我们创建一个List用于保存满足条件的学生的名字
  2. 再对所有学生进行一次遍历
  3. 逐个判断单个学生是否满足了要求,如果满足要求,就把学生的名字加入到满足条件的列表中
  4. 遍历完成,获得结果

那么我们最常见的使用循环迭代的处理方式会是如下:

        public static List<string> GetPass60(Student[] students)
        {
            List<string> result = new List<string>();
            foreach (var stu in students)
            {
                if(stu.Average >= 60)
                {
                    result.Add(stu.name);
                }
            }
            return result;
        }

这样写当然没有问题,我们也获得了我们想要地结果,但是还是显得有些累赘。
如果我们要再取学生平均成绩在90分以上的呢?要取三门成绩相差不超过10分的呢?要取前十名呢?每一次都重新写几个循环吗?无论是复用性也好,还是可读性也好,光是想想我都觉得头大,感觉一坨屎山已经从天而降。

三. 管道处理

如果是用管道的思想又会是怎么样呢?

  1. 选出所有平均分达到60分的学生
  2. 把这些学生的名字加入结果列表中

这时候肯定有同学举手了,“你这明明是耍赖,如果能一次选出来结果,我当然一次选出来了!”
但是要注意,这正是管道编程思想和迭代思想的不同之处,对管道编程的思想来说,所有的学生是同时被“扔”进管道进行处理的,我们要做的其实是两步:先筛选,再把名字加入列表。在这里,“所有的学生”是一个整体,也就是所谓的“集合(Collection)”——而这正是“集合管道(Collection)”的由来。
在这种思想里,所有的学生天然地会被逐个处理,但是这个过程是不在我们的计算过程内的。

talk is cheap, show me the code.
继续进入代码世界。

1. 手写管道

前面有说过,管道天然会逐个处理对象,那么我们首先要做好基础设施建设,针对这次的需求来两根管道。


//筛选符合条件的学生
public static List<Student> FilterStudentPipe(this List<Student> students)
{
    List<Student> result = new List<Student>();
    foreach (var stu in students)
    {
        if (stu.Average >= 60)
        {
            result.Add(stu);
        }
    }
    return result;
}
//将所有符合条件的学生的名字记录下来
 public static List<string> SelectStudentNamePipe(this List<Student> students)
 {
     List<string> result = new List<string>();
     foreach (var stu in students)
     {
        result.Add(stu.name);
     }
     return result;
 }

管道搭好,接下来我们就可以进行操作了:

public static List<string> GetPassStudents(List<Student> students)
{
	//连接两次操作
    return students.SelectStudentNamePipe().FilterStudentPipe();
}
2. 提取操作

这时候肯定又双叒叕有同学要说了,你这还多出来好多行代码,甚至变得又长又臭——但是考虑另一个问题,现在的要求是提取所有平均分60分及以上的同学,但如果要求提取90分以上的呢?
所以下一步我们需要把操作提取出来。

public static class ExcuteClass
{
//增加pass操作,使用外部传入的函数进行判断
    public static List<Student> FilterStudentPipe(this List<Student> collection,Func<Student, bool> pass)
    {
        List<Student> result = new List<Student>();
        foreach (var stu in collection)
        {
            if (pass(stu))//使用pass进行判断
            {
                result.Add(stu);
            }
        }
        return result;
    }

    public static List<string> SelectStudentNamePipe(this List<Student> students, Func<Student, string> selector)//增加selector操作,使用外部传入的函数进行转换
    {
        List<string> result = new List<string>();
        foreach (var stu in students)
        {
            result.Add(selector(stu));//使用selector转换
        }
        return result;
    }

//连接两个函数,把操作放入
    public static List<string> GetPassStudents(List<Student> students)
    {
        return students.FilterStudentPipe(stu=> stu.Average > 60)
                        .SelectStudentNamePipe(stu=>stu.name);
    }
}
3. 使用泛型

到了这一步就足够了吗?
仔细想一想,再进行一层抽象:

我们第一步在本质上其实是传入了一批数据,然后再传入了一个判断是否满足条件的函数,最后返回了所有满足条件的数据
而我们的第二步,本质上其实是传入了一批数据,然后再传入了一个用于转换的函数,最后返回了所有转换完成的数据

注意这个过程是与数据本身是什么类型是无关的!
也就是说,我们现在可以用它来筛选学生,下一次我们可以用同样的步骤来筛选老师!
这一次我们可以从学生身上获取名字,下一次我们可以用同样的步骤来获取学生的分数!

所以我们就有了泛型的函数:

public static class ExcuteClass
{
    public static List<T> Filter<T>(this List<T> collection,Func<T, bool> pass)
    {
        List<T> result = new List<T>();
        foreach (var item in collection)
        {
            if (pass(item))
            {
                result.Add(item);
            }
        }
        return result;
    }

    public static List<TResult> Select<TInput,TResult>(this List<TInput> collection, Func<TInput, TResult> selector)
    {
        List<TResult> result = new List<TResult>();
        foreach (var item in collection)
        {
            result.Add(selector(item));
        }
        return result;
    }

	//注意这个函数
    public static List<string> GetPassStudents(List<Student> students)
    {
        return students.Filter(stu=> stu.Average > 60)
                        .Select(stu=>stu.name);
    }
    
	//获取各种奇奇怪怪的结果
     public static void GetOther(List<Student> students)
     {
         //获取所有名字字数在两个以上的学生的名字
         List<string> name = students.Filter(stu => stu.name.Length > 2)
                                     .Select(stu=>stu.name);

         //获取所有数学和语文都及格了的学生的英文的成绩
         List<int> engScores = students.Filter(stu => stu.maths > 60)
                                       .Filter(stu => stu.chinese > 60)
                                       .Select(stu=>stu.english);
     }
}

第三步我们把筛选和转换的过程完全抽象了出来,注意我们最后的调用函数,完全没有任何变化
而与此同时,我们可以用它们来相当简单地获取各种各样奇奇怪怪的数据,不仅如此,我们还获得了易读性要强得多的代码!

4. 使用LINQ

如果你还觉得每次都要写几个抽象的函数很累,那么如果你使用的是C#,可以直接使用LINQ,LINQ里已经实现了非常多的对集合进行处理的函数——包括我前面实现的FilterSelect

 //我们前面实现的Filter
public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate);
//我们前面实现的Select
public static IEnumerable<TResult> Select<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector);

如果使用LINQ,我们前面的代码只需要改成这样:

 public static List<string> GetPassStudents(List<Student> students)
 {
     return students.Where(stu => stu.Average > 60)
                     .Select(stu => stu.name)
                     .ToList();
 }

 public static void GetOther(List<Student> students)
 {
     //获取所有名字字数在两个以上的学生的名字
     List<string> name = students.Where(stu => stu.name.Length > 2)
                                 .Select(stu=>stu.name)
                                 .ToList();

     //获取所有数学和语文都及格了的学生的英文的成绩
     List<int> engScores = students.Where(stu => stu.maths > 60)
                                   .Where(stu => stu.chinese > 60)
                                   .Select(stu=>stu.english)
                                   .ToList();
 }

对比一下一开始的代码,是不是简洁漂亮了太多?

5. 其他语言

前面这么多例子,都是C#的,但是支持这种思想的远远不只C#,比如javascript同样提供了几个常用的管道函数,例如mapfilter等等,不仅如此,javascript甚至提供了专门的管道操作符,可以将前一个函数的返回值直接传给后一个。

let arr = [1,2,3,4,5,6,7,8,9]
let ascii = arr.map(v=> v * 2)
               .filter(v=>v > 5)
               .map(v => v * 2 + 52)
               .map(v => String.fromCharCode(v));
console.log(ascii); //输出  [ '@', 'D', 'H', 'L', 'P', 'T', 'X' ]
const double = (n) => n * 2;
const increment = (n) => n + 1;

// 没有用管道操作符
double(increment(double(5))); // 22

// 用上管道操作符之后
5 |> double |> increment |> double; // 22

语言之外的扩展

前面我实现了一个自己的“管道”,但是管道的思想远不止如此,我们可以回到文章的开头:把一批操作组合成一个操作序列,然后再把需要处理的一个或一批对象扔到这个操作序列里,在序列的最后获取操作结果。
这个思想可以在很多地方应用,并且已经被非常多的地方应用了:

  • 游戏引擎的渲染管线:
    渲染管线

  • PowerShell的管道

指令A | 指令B | 指令C (enter)
  • CPU指令流水线
    在这里插入图片描述

在这里插入图片描述

参考:

  • https://blog.csdn.net/u014106644/article/details/95209474
  • https://martinfowler.com/articles/collection-pipeline/
  • 12
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值