如何设计一门语言(四)——什么是坑(操作模板)

其实我在写这个系列的第三篇文章的时候就已经发现,距离机器越远,也就是抽象越高的概念,坑的数量是越少的。但是这并不是说,距离机器越近的概念就越强大或者说越接近本质。这是广大的程序员对计算理论的一种误解。大多数人理解编程的知识结构的时候,都是用还原论来理解的,这个方法其实并没有错。但问题在于,“还原”的方法并不是唯一的。很多人觉得,反正你多高级的语言编译完了无非都是机器码嘛。但是还有另一种解释,你无论多低级的语言编译完了无非也就是带CPS变换(continuation passing style)的λ-calculus程序嘛。他们是等价的,不仅能力上也是,“本质”上也是。

一个用CPS变换完整地处理过的λ-calculus程序长的就很像一串指令。而且类似于C++的inline操作,在这里是完全自然、安全、容易做的。那其实为什么我们的机器不发明成这样子呢?显然这完全跟我们想如何写一个程序是没关系的。正是这种冲突让我们有一种“概念距离机器越远运行速度就越慢”的错误的直觉。扯远了讲,就算你在用一门函数式语言,譬如说Haskell也好,F#也好,最终在运行的时候,还是在运行彻底编译出来的机器码。这些语言是完全不需要“模拟器”的,虽然由于各种历史原因人们首先开发了模拟器。当然一个精心设计过的C程序肯定还是要比haskell快的,但是我觉得能这么干的人不多,而且大多数时候这么干都是在浪费老板的钱而已,因为你们的程序原本就不需要快到那种份上。这种东西就跟那些做互联网对于测试的想法是一样的——有bug?发现了再说,先release抢市场。

如果对这方面有了解的话,CPS变换——也就是Lost In Stupid Parentheses-er们最喜欢的call-with-current-continuation,他的另一个名字叫call/cc——是一种跟goto一样强大而且基本的控制流的做法。goto和CPS可以互相转换不说了,所有其它控制流都可以转换成goto和CPS。它们两者在这方面是不相上下的。而且既然一个完全用CPS变换处理过的程序长得就像一串指令,那你说他们的区别是什么呢?区别就是,CPS可以是强类型的,而goto则永远都不可能。

作为废话的最后一段,我给个小例子来讲什么叫“一个用CPS变换完整地处理过的λ-calculus程序长的就很像一串指令”。就让我们用a(b( x ), c( x ))这样的一个表达式来讲:
处理前:

a (b x) (c x)

处理后:

b x λa0.
a a0 λa1.
c x λa2.
a1 a2

用我们熟悉到不能再熟悉的Haskell的Monad的手法来翻译一下其实就是:

a0 <- b(x)
a1 <- a(a0)
a2 <- c(x)
return (a1(a2))

好了,至于上面这种形式(看起来很像SSA)是怎么被做成机器码的,大家自己去看编译原理吧。上面这么多废话就是想表达一个结论:抽象并不意味着负担。当然,至于对程序员的智商上的要求,对某些人也是一种负担,这个我就没办法了,所以就不考虑他了。

===============废话结束================

模板也是这类抽象的一种。为什么我要把标题写成“坑”,只是想跟前面统一一下而已,其实到了模板这么高级的抽象的时候,基本上已经没什么坑了。当然C++的做法就另当别论了,而且我想那些坑你们大概一辈子也碰不到的了。那我们先从简单的讲起。

比模板更简单的东西自然就是泛型了。为什么叫他泛型?因为泛型实际上就是一种复制代码的方法,它本身是没有推导能力的,所以肯定谈不上什么模板了。但是在大多数情况下,泛型这么弱的抽象也已经基本够用了。跟泛型相关的手法大约有三个。

第一个就是定义一个返回一个类的函数(在这里参数是T):

class Array<T>
{
    public Array(int count);
    public int Count{get;}
    public T this[int index]{get; set;}
}

第二个就是,调用这个函数,参数给他类型,帮我们new一个类的实例:

var xs = new Array<int>(10);

这其实有两步。第一步是对函数调用Array<int>求值得到一个T,然后对new T(10)进行求值获得一个对象。只是刚好Array<int>的返回值也叫Array<int>,所以比较混淆视听。

事情到这里还没完。上一篇文章中我们讲到,写一个类是要考虑很多contract的问题的。所以Array<T>是个什么东西呢?他至少是一个IEnumerable<T>:

interface IEnumerable<out T>
{
    // ...
}

class Array<T> : IEnumerable<T>
{
    public Array(int count);
    public int Count{get;}
    public T this[int index]{get; set;}
}

于是有一天我们构造了一个Array<Array<string>>的对象,然后要写一个函数来处理他。这个函数做的事情很简单,就是把这个二维的数组给平摊成一个一维的数组,里面所有的数组头尾相接起来。于是根据上一篇文章的内容,我们写一个接受class的函数,也是要想很多contract的问题的(面向对象就是麻烦啊)。这个函数需要的只是遍历的功能,那我们完全没有必要要求他必须是一个Array,于是我们的函数就这么写:

IEnumerable<T> Flatten<T>(IEnumerable<IEnumerable<T>> xss)
{
    foreach(var xs in xss)
        foreach(var x in xs)
            yield return x;
}

或者你也可以用高级一点的写法,反正是一样的:

IEnumerable<T> Flatten<T>(IEnumerable<IEnumerable<T>> xss)
{
    return xss.Aggregate(new T[], Enumerable.Concat);
}

有CPS变换就是好呀,没有CPS变换的语言都写不出yield return和async await的。但是你们这些搞前端的人,特别是做nodejs的也是,都是教条主义的,觉得eval是evil,硬是把老赵的windjs(曾用名:jscex)给拒了。亏js还是一个特别适合写callback的语言呢,结果没有$await,你们只能把一个好好的程序写成一支火箭了。

那现在问题来了。当我们想把Array<Array<string>>传给Flatten的时候,我们发现Flatten的参数需要的是IEnumerable<IEnumerable<string>>,究竟二维的数组能不能转成二维的迭代器呢?

C++嘛,因为它没有C#的协变和逆变的功能,所以是做不到的了。幸好我们这里用的是C# 4.0。那C#究竟是怎么做的呢?

其实从Array<Array<string>>到IEnumerable<IEnumerable<string>>需要两步。第一步因为Array继承自IEnumerable,所以类型变成了IEnumerable<Array<string>>。第二部就是最重要的步骤了,因为IEnumerable<out T>的T有一个out,这就说明,IEnumerable里面所有的T都是用在函数的返回值上的,他只生产T,不会消耗T。所以一个IEnumerable<子类型>就可以转变成IEnumerable<父类型>,因为子类型总是可以转变成父类型的。因此最后IEnumerable<Array<string>>就变成了IEnumerable<IEnumerable<string>>了。

所以现在我们回过头来看上面提到的泛型的三个手法
1、定义一个输入类型输出类型的函数(class Array<T>
2、调用这个函数来得到你想要的类型(new Array<int>()
3、协变和逆变((IEnumerable<IEnumerable<string>>)new Array<Array<string>>()

所以说白了泛型就是一个对类型进行操作的东西。当然它的内涵远远没有模板那么丰富,但是既然讨论到了对类型的操作,我觉得我要稍微普及一下一个类型系统的常识——父类型和子类型。

父类型和子类型说的是什么,如果我们有两个类型,一个T,一个U。如果一个U的值,总是可以被看成一个T的值的话,那么我们就说U是T的子类型。我们也可以说,U是T的子集。这里我要说一下为什么正方形是一个长方形但是我们却不能让正方形继承自长方形呢?因为这个“是一个”只在只读的时候成立。考虑了写,正方形就不是子类型了。

除了类的继承,协变逆变以外,还有另一种父类型和子类型的关系——那就是模板类型了。举个例子,我有一个函数是这么写的:T Do<T>(T t)。这是一个输入T输出T的模板函数。那我们有一天需要一个输入int输出int的函数怎么办呢?Do<int>就好了。反过来行不行呢?不行。所以delegate T F<T>(T t)就是delegate int F(int t)的子类型。

当然,上面这个例子千万不能和函数的协变逆变混起来了。只有模板才能这么做,delegate string(string)肯定变不了delegate object(object)的。只有delegate string(object)可以通过协变+逆变同时作用变成delegate object(string)。因为object和string是继承的关系,不是模板的关系。

泛型到这里基本上就说完了,轮到模板了。C#的泛型产生的类可以是静态类,其实C++就更可以了,而且C++还有偏特化这种必不可缺的东西。那偏特化有什么用呢?这跟上一篇文章将面向对象的时候一样,其中的一个很大的用处就是拿来做contract。

interface和template(其实是concept mapping)拿来做contract的区别,在于你选择一个具有contract的类型的实现的时候,是在运行时做的,还是在编译时做的。其实有很多时候我们并不想,有时候也不能入侵式地给一个类随便添加一个新功能。我们知道,功能就是contract,contract要么是interface,要么是template。interface并不是总是可以加上去的,譬如说对那些不能修改的类,就像string啊int什么的。而且我们也不会拿string和int做多态。这种时候我们就需要concept mapping了,然后靠类型推导去选择正确的实现。在C++里面,就是用template+偏特化来做了。

上面这段话说得好像很抽象(一点都不抽象!),我们还是用一个生动的例子来做吧。譬如说我们要做序列化和反序列化。首先我们可以让若干个类型支持序列化的功能。其次,只要T支持序列化,那么vector<T>啊、list<T>什么的也将自动支持序列化的功能。这是一个递归的描述,所以用template来做刚刚好。但是像vector和list这种不能修改的类,或者int这种原生的类型,我们要怎么支持序列化呢?不能修改类,那只能写在类的外面,变成一个函数了。那对于vector<T>来说,他怎么知道T的序列化函数是什么呢?相信熟悉模板的读者肯定知道正确的答案是什么了。

首先我们要做一个空类叫Serializable<T>,其次不断地偏特化他们,覆盖所有我们需要的类型:

template<typename T>
struct Serializable
{
};

template<>
struct Serializable<int>
{
    static int Read(istream& i);
    static void Write(ostream& o, int v);
};

template<>
struct Serializable<wstring>
{
    static wstring Read(istream& i);
    static void Write(ostream& o, const wstring& v);
};

template<typename T>
struct Serializable<vector<T>>
{
    static vector<T> Read(istream& i);  // 我们已经有右值引用构造函数了,不怕!
    static void Write(ostream& o, const vector<T>& v);
};

这里需要提到的一点是,当我们使用Serializable<vector<T>>的时候,我们首先要保证T也是Serializable的。可惜的是C++并不能让我们很好地表达这一点,本来C++0x是有个concept mapping的标准,不过后来被干掉了,我觉得永远都不会有这个东西了。但其实这并不是什么太大的事情,因为只要你写错了,那总是会有编译错误的。

不过go语言在这一点上就不一样了,go没有模板什么像样的代码都写不出就不说了,就算他有模板,然后同时具有Read和Write的类型都实现了go的一个叫做Serializable的interface的话,结果又如何呢?其实这相当于把Serializable<T>::Read(i)和Serializable<T>::Write(o, v)都改成Read(i, &v)和Write(o, v)。这样的问题老赵已经在他的博客讲过了,万一Read和Write不是你写的,功能跟你要的不一样,只是碰巧有了这两个函数怎么办,你还能救吗?你已经不能救了,因为名字都用过了,你想显式地实现一个interface的话go又没这个功能,于是程序就到此傻逼了,Read和Write改名吧,祝你不是项目快写完了才发现这个问题。

关于编译错误,我觉得有一个事情是很值得说的。为什么熟悉Haskell都觉得Haskell的程序只要经过了编译基本上运行就靠谱了?其实并不是Haskell的程序真的免去了调试的这一步,而是因为这门语言经过了精心的设计,把本来在运行时才检查的事情给转到了编译时。当然这有一个不好的地方,就是我们用C语言来写一个程序的时候,虽然因为C语言抽象能力太差被迫写的很糟糕,但是我们总可以运行一点改一点,最终让他可以执行。Haskell就不一样了,只有能编译和不能编译两种状态,你要不断的修改程序,让他可以编译。一旦可以编译,一般就好了。Haskell的这种特性需要淡定的程序员才能使用。

为什么呢,因为Haskell是没有语句的,所以只要你修改了函数让他做了不一样的事情,那么函数的类型就会发生变化。那么所有依赖到这个函数的函数的类型也会发生变化。如果你改错了,那类型检查就会过不去,然后你的程序就不能编译了。Erik Meijer菊苣说得好,函数的类型才是表达函数业务逻辑的地方。而之所以要函数体,那是因为编译器不够聪明,得让你告诉他满足这个类型的最简单的解是什么。

所以如果我们在C++也采用这种写法的话——其实也就是把逻辑都交给template+偏特化,或者继承+visitor来做,那么也会有一样的效果,虽然并没有Haskell那么严格。一旦你进行了本质上的逻辑的变动,那你的类型一定会受到影响,那不满足类型要求的地方编译器就会帮你找出来。所以,当你看到一个因为这种情况而产生的编译错误的时候,心理要想:“好棒,编译器又给我找出了一个错误,避免我在运行的时候才苦逼的调试它!

当然,模板的这些手法,可以很轻易地用在continuation passing style变换啊、combinator(很像设计模式但其实不是的东西)啊、async啊actor等各种强类型的物体上面,不过这些东西我今天就不打算细说了。当我们在做类似的事情的时候,我们要把类型设计成能表达业务逻辑的那种形式,从而让编译器查更多的东西,把运行时错误尽量化简为编译错误。

当然,C++的template比concept mapping还要更牛逼一个等级的,就是可以做type traits。如果是concept mapping是在对值通过类型进行分类采用不同的计算方法的话,那么type traits就是用来直接对类型进行计算的。那什么是对类型进行计算呢?我来举个例子。

譬如说我们要对一个vector进行排序,但是这个时候我们不像std::sort一样直接给出一个比较函数,而是通过从T拿出一个U当key的方法来做。譬如说对Student类排序,我们可以用他的学号、成绩、姓名等等任何一个属性来排序,这还是一个相当有用的作法。当然,我们总是可以写一个lambda表达式来做出一个用某个属性来比较Student类的函数,然后让std::sort来解决。很可惜的是,现在team的编译器还不够新,不支持C++11,怎么办呢?于是我们决定撸一个自己的sort函数(虽然这种事情是不推荐的,但反正是个例子,就不纠结这个了):

template<typename T, typename U>
void Sort(vector<T>& ts, U(*key)(T))
{
    for(int i=0;i<ts.size()-1;i++)
        for(int j=ts.size()-1;j>i;j--)
            if(key(ts[j-1]) > key(ts[j]))
                swap(ts[j-1], ts[j]);
}

这段冒泡排序看起来没什么问题对吧,无论用什么语言最后写出来都肯定是这个样子的。于是我们写了几个单元测试,譬如说用sin来对double排序啦,求个负数实现逆序啦等等,觉得都没问题。于是开始投入实战了!我们写了下面的代码:

int GetScore(const Student& st)
{
    return st.score;
}

vector<Student> students;
....
Sort(students, &GetScore); // error

为什么会错呢?因为GetScore函数接受的不是Student而是const Student&!这下可麻烦了。我们有些函数接受的是T,有些函数接受的是const T&,难道要写两个Sort嘛?当然这种代价肯定是不能接受的。于是我们想,如果可以从U(*key)(T)的T推导出vector里面装的是什么那该多好啊。反正无论函数接受的是string, string& const string, const string&,vector反正只能放string的。

这个时候就要祭出伟大的type traits了。怎么做呢?其实根上面的说法一样,我们把template看成是一个函数,输入是一个类型,输出再也不是值了,还是一个类型就好了:

template<typename T>
struct RemoveCR
{
    typedef T Result;
};

template<typename T>
struct RemoveCR<T&>
{
    typedef typename RemoveCR<T>::Result Result;
};

template<typename T>
struct RemoveCR<const T>
{
    typedef typename RemoveCR<T>::Result Result;
};

我们要怎么看这个过程呢?其实这是个pattern matching的过程,而pattern matching在一定程度上跟if-else其实是差不多的。所以我们看着一类的东西,心里不要总是想着他是个模板,而是要想,RemoveCR是个函数。所以当我们看见第一个RemoveCR的时候,我们心里浮现出来的景象大概是这个样子的:

Type RemoveCR(Type T)
{
    if(false);
    ....
else
        return T;
}

好了,这个时候我们看见了第二个RemoveCR,那么这个RemoveCR函数又具体了一点:

Type RemoveCR(Type T)
{
    if(false);
else if(T is T0&)
        return RemoveCR(T0);
    ....
    else
        return T;
}

后来我们看到了第三个RemoveCR,发现定义到此结束了,于是RemoveCR函数的实际的样子就出来了:

Type RemoveCR(Type T)
{
    if(false);
    else if(T is T0&)
        return RemoveCR(T0);
else if(T is const T0)
        return RemoveCR(T0);
    else
        return T;
}

于是我们就可以做很多验证了,譬如说RemoveCR<int>::Result的结果是int,RemoveCR<const int&>::Result的结果还是int。现在好了,我们可以修改我们的Sort函数了:

template<typename T, typename U>
void Sort(vector<typename RemoveCR<T>::Result>& ts, U(*key)(T))
{
    ....
}

无论你的排序函数接受的是Student还是const Student&,现在Sort函数都知道,你需要对vector<Student>进行排序。于是任务就完成了!

别的语言里面没有这种问题,是因为只有C++才会把const、volatile和&这样的东西用来修饰一个类型而不是一个变量。这一点我第二篇文章已经讲过了,就不继续啰嗦了。所以说C++的设计虽然考虑得很周到,Bjarne Stroustrup菊苣也说过他不喜欢像别的语言的作者一样把自己的观点强加给用户。从这个出发点上来看,C++这一点相当好。只要你肯学习,又不会太蠢的话,总是可以学会C++的正确使用方法,正常使用C++来写代码的。但是,人真的蠢怎么办呢?Bjarne Stroustrup你这样歧视愚蠢的程序员是不对的,难道蠢就不能做程序员吗,难道学了go陷进去不能自拔的人就再也没有机会学习C++了吗!

关于type traits,haskell的type class(这东西就跟concept mapping一样)其实也有一部分这样的能力,可以帮你从type class的一部分类型参数推导出另一部分的类型参数。譬如说你这么写:

class MyClass a b c : a b => c
  where ....

那么只要你实现了MyClass Int String Char,就不能实现MyClass Int String String了,因为a b => c这条规则已经限制了,(Int String)只能出现一次,c要完全被a和b决定。所以拥有这个=>的haskell的type class也就可以有一部分type traits的功能了,虽然用法跟C++是截然不同的。

那C++的template除了泛型、concept和type traits之外,还有没有别的功能呢?当然有,我们不仅可以把类型放进模板的参数列表里面去,也可以把一个整数放进去。这个时候我们就可以用Int<100>来表达100,用Pair<Int<100>, Pair<Int<200>, Pair<Int<300>, PairEnd>>>来代表数组[100, 200, 300],然后各种奇技淫巧就可以用来把模板写成一个不带类型的函数式语言了,在编译期什么东西都可以算。这个事情展开讲就太复杂了,而且也没什么用,你们感兴趣的话去看《C++ Template Metaprogramming》就行了。

posted on 2013-05-12 16:34  陈梓瀚(vczh)  阅读(3979)  评论(9编辑  收藏  举报