起因

之前因为作业,写一个多项式计算的实现(来自课《计算机代数基础》,教材料《Ideals, Varieties and Algorithms》)。其实遇到了些问题,在data构建中,传入了太多子类型的参数,又将操作类型类化,导致实例化时出现了编译不通过(主要为无法deduce),根本原因在于对于一个forall的返回值类型,在其它函数中调用时,如果存在约束条件,是无法duduce的。哪怕不是约束,只要存在一定的类型限制就是不行的。这其中查找了相当的内容,也有解决方案,但并没有直接去修正。这源于我对类型类的一些理解问题。

实践

实践中如何使用类型类是非常明确的。它提供了一种类似于函数重载的功能,但同时也具有自动推演、对不可控data进行instance等能力。这类信息网上能查到非常非常多,可以看一下y2b中的_typeclass vs world_这个视频。同时haskell的类型类除了针对不同数据结构提供同样语义的操作外,还包含了自动推算和约束等功能。

这里想说明几点,一些教程里会将类型类类比为虚函数或者接口。但这是非常不正确的。一是在继承中,我们继承下来的东西,是一种可以使用,但在子类中必需要被调用的存在(指在子类的成员中被调用)。而类型类的约束不同,约束是需要你使用的,不然就不存在意义了。对象描绘的是物体,而类型类是一种逻辑、算法。

解决方案一

这仅仅只是理解了类型类,还没有处理起因。处理起因最直接的手法有两种,一种是在类型类的类型变量里增加参数,另一种是使用type families。前者会引出另外一个问题,instance时的类型匹配,如果构造的数据存在过多的聚合,会导致匹配困难(哪怕开起扩展),也会使得实例化的参数显得冗长。如:

newtype V a b c = V { V :: a b c }
newtype V2 b c = V2 { V2 :: Seq b c }

当然这样的做法对haskell里是没有意义的(理由下面再说),但是对V2进行实例化时,就会因为Seq等参数原因导致instance非常冗长,不然就是使用type families。而type families起引也是用于处理类型问题存在的。因此这样一来对于存在如下的代码,就可以使用type families来进行处理。

class C a b c where
    f :: Ord b => a b c -> b
    func :: a b c -> a b c -> Bool
    func x y = f x >= f y  -- ^ compile error: cannot deduce 'f x' and 'f y'

-- | use type families
class C a where
    type OrdC a
    f :: a b c -> OrdC a
    func :: Ord (OrdC a) => a b c -> a b c -> Bool
    func x y = f x >= f y 

思考

寻找上述解决方案时,去stackoverflow上问了相关问题。说到了是否有必要使用类型类。让我就去查起来了各种资料。

最初,我将类型类当成是一种逻辑聚合。数据由类型类进行限制。类型类代表了一系列操作(逻辑图象),而多种操作集合就够成我们的程序(世界)。所以,遇一个问题时,我的第一反应就是构建类型类。但这是有必要的么?在实践时,如果没有人会使用的我的类型类,那么这种存在还有意义么,恐怕意义仅存于一种表达了。

之后看到了_type class vs world_这一视频。里面提到了,haskell可以解放data。不再像是面向对象里那样,data和algo绑在一起,数据是没有限制的。限制是加在调用数据所有的函数上。所以我们思考问题的方式应该是先去思考我们要什么样的功能,最后再去设计数据。比如说一个多项式,有度,有首项,先设计出获取这样的函数,然后组合操作得出算法,最后再去应用数据(在面向对象里,度、首项等可能直接设计成数据内了,如果需要一层函数间接获取,也是特定于数据而言的,思考的起点是不同的。当然,双方思考起点也是能互换的,只是没这个必要。)

阅读代码

那么我们什么时候去设计一个类型类会比较好呢?虽然说只要有人会用就有意义,但何时才是会有人用呢。我查找了一些现有的项目(但查找的内容有限,概括不全面,仅仅表达一个思想)。

不使用的情况

  • base内Maybe、Either都不是类型类,仅仅数据加函数。因为他们是少有歧义的,且不太会出现多种不同的数据结构;
  • containers库中,各种数据结构。这里重点是结构,而不算法。具体算法是基于结构的情况,或者说,在一个程序内,我们不太需要同一种结构的多种不同实现。
使用的情况
  • base内的Monad、Functor等,他们是基于范畴论的,高度的抽象,可应用于任何场合;
  • aeson、base内fromJSON、fromList等,这种应用于转换关系,或者说设计类型类的开发者就可以对内建的数据类型进行instance;
  • XMonad内,对于不同的Layout使用了LayoutClass的约束和default等,这类就是上面据说的不在data上做限制,不同的Layout的data使用不同的方式进行设计,但最后统一同样的操作。这种情况有一个非常大的关键点,layout的data,是完全解放的,没有限制的;
  • hspec库中的Example,与上一点相同。

结论

在实践中,当且仅当一组操作,会由别人或者自身多次使用时,再去考虑类型类,不然就是一种多余的行为。而且由于函数式思考方式的特点,从特定的一组函数转换成一个类型类,并不是多么麻烦的事情。

再思考

处理掉类型类这一疑问后,就不代表没有问题了,上面也提及了放开data的限制。那么,What and How?至于Why就不用问了。

放开data的限制,扔掉所有的聚合、继承的想法,限制是放置于函数上的。聚合的设计,在这种场合让编程变得困难。data无意义的层级化是多余的。比如最上面写到的V和V2。哪怕是重载/重用/其它情况,也是针对类型类(你的接口)配合别人的data。而不是面向对象里,继承还是存在成员对象的(当然,真正意义的接口也是只有成员函数的。然而这两者的思想在重用之后的一步就也完全不一样了。)。

另外一个问题,在于类型类的约束。他们不同于继承,不是一层一层由单一向下展开的。而应该是平铺出来的,仅仅有部分少有情况才会出现约束。上面也提及了,思考的起点不同。逻辑之间并列的情况效多(层层推进算是演绎)。所以,自然不存在像面向对象里那样夸张的类关系(当然,随着系统复杂度提升,肯定也会存在一定的层级的,只是不会那么夸张。)。

理想,逻辑与哲学

我最初,将haskell思考成为了《逻辑哲学论》中的一种实现。类型类就是逻辑图像。由对象(data)和逻辑图像(类型类)描绘(一般函数的组合)实际组成世界(程序)。这里先说明一点,就是如上只是我个人从《逻辑哲学论》中自我想出的观点,与实际的观点是有偏差的。

这一理想之中存在一个很庞大的问题,就是实际中,不可能有那么多抽象出来应用于一切的逻辑(图像)。有些需要应用于一切,但并不构成群。那么,类型类的哲学到底是什么呢?

世界,是由多种物体的描绘组成的。而这些描绘,并非全部可以应用于一切对象。有些他们仅应用于个别对象。那种可应用于多种对象的描绘/逻辑,就可以理解成为类型类吧。同时,我们所写的程序,是远简单于真实世界的世界,自然也不存在非常多层次以及可以重用设计的类型类了。是的,要认识到,我们所编写的世界,远不如我们真实世界那么复杂。

这一层想通之后就比较轻松了。再进一步,对于各种函数,我们同时也能理解为一种命题,一种表达我们思想的语言。命题、逻辑、思想与语言就这样子组合起来了。注:这里是有问题的,在逻辑哲学论中这并非简单的等价(毕竟我还未过多深入)。