Hoes of Tech

Conceptualization of Technology

OOC, Generics and Flawed Design

昨天在github上讨论OOC的一些特性,随后这个语言的设计者写了这篇文章。 虽然大部分都在谈ooc的编译器设计,但更多的内容在于程序设计的思想,复杂度,维护上面。我希望这篇文章能对读者有哪怕一丁点的帮助。

原文地址:http://fasterthanlime.com/blog/2015/ooc-generics-and-flawed-designs

ooc可能是我(注:原作者)最得意的成果,但同时,对我来说它也是让我头疼不已的“刺头”。

一个重要的原因就是它的设计(构架)并不让人满意,并且在当下,很多东西没法简单的解决。但不要误解: 在某种意义上,所有的设计都是“糟糕”的,不论是由一个人掌管,或者有社区驱动。没有任何一个设计能被称为完美。尽管我们并没有关于“完美”的定义和度量。

现在回到ooc上来,一些话题反复的被提起,比如interface(接口)和cover(覆盖)。这个问题的原因一部分来自与人们会简单的认为这些概念应该跟其他语言里的一样,因为它们的名字完全相同。但事实并不是这样,纵使拥有相同的名字,它们的意义也不尽相同。如果这些概念跟其他语言里的概念完全一样,那么ooc会是一个简单的C++/Java/Ruby的底层实现。但ooc不是,ooc与上述每一种语言都有重合,而我(注:原作者)和过去六年里一起帮过我了人们,则尝试着把这些特性融合到一起。

关于泛型

这是ooc里对于泛型的定义: 它(范型)是一个指向未知类型的指针,同时保存着对应的值(with value semantics)。

首先让我们来看一个简单的泛型函数(generic function):

add: func <T> (a, b: T) -> T{
    a + b
}

译者注: 对不熟悉ooc的人,解释一下上面的代码: 上面的代码定义了一个叫做add的函数,参数是a,b(泛型),返回值是二者之和。

现在,如果ooc的generic(泛型)是template(模板),那么这段代码没有任何问题——因为它会根据类型来生成“add”的实现。如果ab的类型不一样,抛出错误,如果T没有“+”的运算符重载,抛出错误,等等等等。

但这并不是ooc里泛型的工作原理。对于一个ooc的泛型,在__“运行时”__,你所知道是只有:

  • 它在内存的哪一部分(指向它的指针)
  • 它是什么类型(名字+大小+指针大小)

但在__“编译时”__,你什么都得不到。对,什么都得不到。在编译期,你能对泛型所做的就是“复制(移动)它到不同地方”。对于Collections(集合)来说,这些足够了,因为在绝大多数情况下,ArrayList<T>, HashMap<K,V>等等,并不需要知道关于里面元素类型的太多信息。

好吧,我撒了个谎,他们仍然需要知道一些内容。但大多数情况下是针对比较和哈希。比如,ArrayList<T> indexOf(value: T) -> Int,我们需要比较两个T类型的值,否则我们没法从数组里面搜索到它。

对于HashMap<K,V>,我们则需要对“K”做哈希,否则我们没法将值保存起来,也没法在搜索的时候得到键的哈希值。同样,为了避免冲突,我们还需要比较K的值。

在ooc的标准库(注:在ooc里,标准库被称作sdk),我们是怎么做的呢?我们用了一些小技俩(cheating)。你应该记得我说过,在运行时,我们只知道泛型的地址和类型。好的,我们可以这么写:

add: func <T> (a, b: T) -> T {
    match T {
        case Int =>
            a as Int + b as Int
        case =>
            Exception new("Don't know how to add type #{T name}") throw()
    }
}

现在,很多人会开始认为这是一个十分糟糕的设计,我(注:原作者)甚至不应该称这个为“泛型”,等等。但这就是它的工作原理。纵使向6年前的我抱怨为什么选了这么一种设计,对我来说也没有任何意义。我仅仅是在试着解释它的工作原理,从而消除未来可能发生的误解。

好的,让我们回到HashMap(注:标准库里的一个实现),当你建立一个HashMap时,它会跟上面类似,根据K的类型选择最好的哈希函数,这个取决与K是不是字符串,是不是数字,或者是其他的什么东西。

类型值(Type as values)

在Github的一个问题报告里(注:就是我报告的……),有人说对于下面这个函数

identity: func <T> (t: T) -> T { t }

这段代码可以运行:

identity(42)

但是这个不能:

identity<Int>(42)

但如果你定义了一个范型类:

Cell: class <T>{
    t: T
    init: func
}

那么你就可以用任意一种:

cell := Cell<Int> new()

通常,这是一个糟糕的,但同时多少保持了内部一致性的设计。对于这种东西,有一个原因。

对于Cell(泛型类),构造器(constructor)没有接受任何关于T的实例——也就是说在构造器里,我们没法推断T的类型,因此我们需要在尖括号里指定一个类型。泛型可以作为类型参数——这是它的工作原理。

但函数不同。在相同的情况下,如果推断得来的类型不是你想要的类型(在下面的例子里是SSizeT),那么你总是可以在调用函数是强制转换成你想要的:

identity(42) // -> SSizeT
identity(42 as Int) // -> Int

有些时候,有可能根本没法在编译期根据参数推断类型,比如:

getSome: func <T> -> T{
    match T {
        case Int => 
            4 // guaranteed by fair dice roll
        case Float =>
            0.8
        case => 
            raise("Can't get some of #{T name}")
    }
}

这个函数永远不可能被正常调用。在别的语言里,你可以简单的用getSome<Int>()或者getSome<Float>()里指定类型,但在ooc里,你不能。取而代之,你需要把类型T放进参数列表里,比如:

getSome: func <T> (T: Class) -> T{
    match T{
        // etc...
    }
}

现在当你可以通过getSome(Int)或者getSome(Float)来调用这个函数了。再次回来,在这个问题上,我们总是可以无休止的去争论到底那一种才是更好的方法。对于其他的人来说,“下面这样是可以的:传递类型时用见括号,传递参数时用园括号”,而六年前的我,则对于标记有着独特的信念:类型仅仅是一个值,跟其他的值一样,我们不必在它们的周围加上各种各样的限制(注:意指括号)。

六年前的我是这么认为的:如果我们有一个“partial”的原语,那么我们就能简单的把getSome(Int)转换为getSomeInt,然后在其他地方用这个新函数,并认为它返回Int型:

// fictional code (6-years-ago-me way)
// 伪代码(6年前的我的做法)
getSome: func <T> (T: Class) -> T { /* see above */ }
getSomeInt: partial(getSome, Int)
eng := GameEngine new()
eng setRandomNumberGenerator(getSomeInt)

反过来看,在尖括号风格下面,你能怎么做? 好吧,跟你自己定义一个新的函数并没有什么太大区别,或者使用闭包:

// fictional code (6-years-ago-me way)
// 伪代码(6年前的我的做法)
getSome: func <T> (T: Class) -> T { /* see above */ }

eng := GameEngine new()
eng setRandomNumberGenerator(|| getSome<Int>())

这样你失去了一些高阶(high-order)函数工具。再次重申,这在目前的ooc里并不是一个大问题,因为标准库里根本没有partial这么一个东西。

回到值上来

我前面已经提到,相对范型值做一些有用的动作是很困难的。除非你把它们强制转换回“实”类型:

someFunction: func<T> (t: T){
    // `t` is kind of useless
    // `t`没有任何用处
    u := t as Int
    // Now, with `u` we can do anything we want.
    // 现在,我们可以对`u`做我们想做的事情了
}

把泛型转换成实类型是一个很危险的操作。因为一旦类型错误,我们就只能得到一些乱七八糟的乱码——编译器并不会做任何检查,因为它相信你知道自己在做什么(当然,事后来看,有可能是错误的操作)。但六年前的我反驳,有一种相当不错的机制可以让我们相当安全的把泛型转换会实类型:

someFunction: func <T> (t: T) {
    match t {
        case u: Int =>
            // we're sure `t` was an Int and now we can use it as u
            // 我们确定`t`是Int类型,因此可以用u
        case =>
            // error handling goes here.
            // 这里将会抛出错误
        }
}

这些就是泛型的工作原理的一切。在上面的设计之上,(类型)推断器应该相当的聪明(仅仅是设想……),这样你可以省略尽可能多的类型判断。

举个例子,下面这段代码在前面叙述的系统里是可以正常执行的:

Sorter: class <T> {
    compare: Func (T, T) -> Int
    init: func (=compare) {}
    sort: func (l: List<T>) -> List<T> { /* ... */ }
}

s := Sorter<Int>(|a, b| (a < b) ? -1 : (a > b ? 1 : 0))
s sort([1, 2, 3] as ArrayList<Int>)

“维护”是一个艰难的工作

现在,每当有人误解泛型,覆盖,接口,并且强调他们应该怎么工作,因为“在语言X或Y里有同样名字的特性”,我都不知道该如何应对。

有些时候,它们是正常的bug,我们可以修复它。对于这种情况,我很乐意用我的空闲时间来帮一些忙。但有些时候,这是不可能的。这经常是一个二选一问题:

  • 对应1%的情况,“撞大运”,给出12种人们应该知道的状况(直到下一个人踩到另一个地雷,然后抱怨这个问题,这看起来好公平!)。在已经补丁累累的编译器上不停的打新补丁。
  • 选择大路。讨论这个特性应该怎么运转,然后重写10%,20%,或者50%的编译器代码(6年前的我还不懂的怎么去写一个真正的模块化编译器,却依然向里面塞了一堆东西——之后看起来很棒,现在确麻烦不断),然后80%的现存代码都无法编译(并不是很多,但依然有成百上千行,并且人们每天都在用,包括我)

第一个想法,它经常被用到。但它并不解决问题。当你有一个好的,坚实的设计之时,补丁可以让你想要的东西变得更好。但当你的设计很糟糕是,就像6年前的我,继续添加补丁仅仅是九牛一毛,并且在技术上也是站不住脚的,并且会有更多用户会在这条路上满怀失望。

这就像在你的游戏里,你有一个问题: “玩家不能爬不平的面”。然后会有人站出来说,“看,在第三关(World3)里,湖后面的小山里有个梯子,但似乎没法爬,这是补丁”。然后这个补丁是只要是来自与IP“174.”的玩家,站在梯子下面,然后按住Ctrl+Shift+Alt向上看,就会被立即传送到顶端。当然,“你没法下梯子,这会在下个版本里解决!”

试想如果没有下个版本,因为开发者注意到这个问题远比自己最初的想象(爬湖后面小山的梯子)要复杂的多。只有光滑平面才能工作。因为他们并没有时间去重写整个游戏引擎,使得不光滑平面也能在考虑返回之内。因此他们开始看下一个游戏。纵使这个补丁被采用,也仅仅是烂在那里——因为没有人会想到去按Ctrl+Shift+Alt的同时去看某个特定的位置。

对你,游戏开发者,来说,你应该把“游戏只支持在光滑平面”写进文档。但你可能并不想这么做,因为你可能会羞与记录它,因为它象征着你没有时间和精力(别人会认为是技术)去修复这么一个问题。总之,作为一个专家,你应该做的更好。

于是你没有把这个问题放到游戏主页上,因为那对宣传是莫大的负作用。这是一个很好的选择,它平衡了谎言和对自己的伤害。并且,你开始另一个游戏工程,但你依然会偶尔回到之前那个游戏里,去玩一玩,然后加点因东西。因此它并没有完全的被抛弃,但已经不再是你的重点。对于长期玩家来说,这非常棒,因为他们知道这些事情,并且他们依然能从光滑平面里获得很多快乐。

结论

在这个比喻变得冗长之前,我该收笔了。但希望你能明白我再说什么。当你在处理一个设计糟糕的系统时(某种概念上,是程序设计里的所有东西),有些时候它值得你去尝试,并且改变一些东西,从而让它变得更好。但大多数时候,最好的方法是在这个糟糕设计的系统的限制之下,试一下它到底能不能完成你想要的目标。如果不能,那么最简单的方法就是换到别的(设计糟糕的)系统里(然后你会后悔,因为这个系统没有之前那个系统里你想要的特性……,然后重复)。

下面这些曾经在我的睡梦中挥之不去:怎么才能让它“完美”?它到底是不是符合每个人的用途用法?代码是不是漂亮?速度呢?浪费光阴的年轻人,到底能不能在生命终场之前也继续写代码?

但现在不会了(至少不像以前那样),因为现在我很满足与组合这些设计糟糕的系统,然后去做些我想做的事情。我也会留下点美丽的片段来让我的享受更具有艺术性。有些时候是雪人(Unicode Snowman,unicode的字符之一),有些时候是奇怪的提示信息。这很幼稚,这篇文章看起来像是在逃避责任,在看到有人希望能够改变ooc的大半,从而让他变得更好时,我依然会叹气,但我认为这不会发生。那是C++或者Scala的角色,我不想去承担那种责任。