λ

(conj clojurians me)

起源

泛型编程是一种编程风格,其中算法以尽可能抽象的方式编写,而不依赖于将在其上执行这些算法的数据形式。

泛型编程的提出者

泛型这个词并不是通用的,在不同的语言实现中,具有不同的命名。在Java/Kotlin/C#中称为泛型(Generics),在ML/Scala/Haskell中称为Parametric Polymorphism,而在C++中被叫做模板(Template),比如最负盛名的C++中的STL。任何编程方法的发展一定是有其目的,泛型也不例外。泛型的主要目的是加强类型安全和减少强制转换的次数。

Java中的泛型编程

在Java中有泛型类和泛型方法之分,这些都是表现形式的改变,实质还是将算法尽可能地抽象化,不依赖具体的类型。

generics add a way to specify concrete types to general purposes classes and methods that operated on Object before

通用的类和方法,具有代表性的就是集合类。在Java1.5之前,Java中的泛型都是通过单根继承的方式实现的。比如:

1
2
3
4
5
6
7
public class ArrayList // before  Java SE 5.0
{
public Object get(int i)
public void add(Object o)
public boolean contains(Object o);
private Object[] elementData;
}

虽然算法足够通用了,但是这样会带来两个问题。一个是类型不安全,还有一个是每次使用时都得强制转化。减少类型转换次数比较容易理解,在没有泛型(参数化类型)的时候,装进容器的数据,其类型信息丢失了,所以取出来的时候需要进行类型转换。
例如:

1
2
3
4
5
List list = new ArrayList();
list.add(1);

assertThat(list.get(0), instanceOf(Integer.TYPE));
assertThat((Integer)list.get(0), is(1)); //存在强制转换

因为这个类里只有Object的声明,所以任意类型的对象都可以加入到这个集合当中,在使用过程中就会存在强制到具体的类型失败的问题,这将丧失编译器检查的好处。

1
2
3
4
5
6
List list = new ArrayList();
list.add(1);
list.add("any type");

assertThat(list.get(1), instanceOf(String.class));
assertThat((Integer) list.get(1), is(1));//-> java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer

2005 Java SE 5引入了泛型,不仅有效地提高了算法的通用程度,同时也保留强类型语言在编译期检查的好处。

Generics This long-awaited enhancement to the type system allows a type or method to operate on objects of various types while providing compile-time type safety. It adds compile-time type safety to the Collections Framework and eliminates the drudgery of casting.

所以上述的程序会写成这样:

1
2
3
4
5
List<Integer> list = new ArrayList<Integer>();
list.add(1);
// list.add("no way"); 编译出错
assertThat(list.get(0), instanceOf(Integer.TYPE));
assertThat(list.get(0), is(1)); // 不需要强制转换

类型安全

在静态强类型语言中,编译期间的检查非常重要,因为它可以有效地避免低级错误。这些低级错误就是类型安全解决的问题。类型安全包含了赋值安全和调用安全。其底层实质上就是在某块内存中,始终存在被同种类型的指针指向。

  1. 类型赋值检查
    1
    2
    long l_num = 1L;
    int i_num = l_num; // 编译错误
    在强类型的语言当中,类型不一致是无法互相赋值的。

2. 类型调用检查
Clojure就是一门强类型语言,而且还是一门函数式语言,所以重新赋值不被允许,它的类型安全表现在针对类型的调用安全。

1
2
3
user=> (+ "" 1)
...
java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Number

这里存在一个隐式类型转化的过程,但是由于String无法转化成Number,所以方法调用失败。由于Clojure是动态语言,所以只有在运行时才会抛出错误。

另一个简单的例子,如果一个类型不存在某个方法,那就没法去调用它。在动态强类型语言中,运行时一定会报错。其实质是类型是内存堆上的一块区域,如果该区域之上没有想要调用的方法,那么调用在编译期或者运行期间一定会出错。

1
new Object().sayNothing() // 编译出错

为什么说类型安全对于开发人员友好,这个特性对于编程语言很重要?其实这可以追溯到三次编程范式解决的根本问题上。Clean Architecture(架构整洁之道)一书中,对结构化,面向对象和函数式编程语言做了很透彻的分析。

首先我们得明确一点,这些范式从来没有扩展编程语言的能力,而是在不同方面对编程语言的能力进行了约束。

  1. 结构化编程
    对程序的直接控制进行约束和规范,goto considered harmful.
  2. 面向对象编程
    对程序的间接控制进行约束和规范,pointer considered harmful.
  3. 函数式编程
    对程序的赋值进行约束和规范,mutability considered harmful.

按照这样的思路,泛型编程无非是对既有的范式做了进一步的约束。泛型编程旨在对程序的间接控制进一步进行约束和规范。它把类型安全放在第一位,而将类型转化限制在编译期间。

我们甚至可以遵循前面的定义方式,说:
2.1 泛型编程
对程序的间接控制进一步进行约束和规范,type casting considered harmful.

Kotlin中的泛型编程

variance - 变化
和Java泛型中的泛型方法和泛型类概念类似,Kotlin将对应的概念称为参数化函数和参数化类型。

parameterized function 参数化函数

假设我们要返回三个对象中任一一个对象,同时保证类型一致。参数化函数是很恰当的选择。

1
fun <T> random(one: T, two: T, three: T): T

parameterized type 参数化类型

除了参数化函数,类型本身也可以定义自己的参数化类型。比如:

1
class Dictionary<K, V>

bounded polymorphism 限定参数化类型

大部分情况下,参数化类型不会是无限抽象的,无限抽象往往不利于语言的表达性。所以限定的参数化类型应运而生。

1
2
3
4
fun <T : Comparable<T>> min(first: T, second: T): T {
val k = first.compareTo(second)
return if (k <= 0) first else second
}

如果需要用多个边界来限定类型,则需要用到where语句,表达T被多个边界类或者接口限制。

1
class MultipleBoundedClass<T> where T : Comparable<T>, T : Serializable

invariance 不变

invariance 不变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
open class Animal
class Dog : Animal()
class Cat : Animal()
class Box<T>(val elements: MutableList<T>) {
fun add(t: T) = elements.add(t)
fun last(): T = elements.last()
}

fun foo(box: Box<Animal>){
}

val box = Box(mutableListOf(Dog()))
// -> val box: Box<Dog> = Box(mutableListOf(Dog()))
box.add(Dog()) // ok
box.add(Cat()) // 编译错误

这里出现的编译错误,原因是box的真实类型是Box<Dog>,所以尝试向Box<Dog>中添加Cat对象是不会成功的。这样总能保证类型安全。

DogAnimal的子类型,那么编译器是否承认Box<Dog>Box<Animal>的子类型,在使用时进行隐式转换呢?

1
val box: Box<Animal> = Box(mutableListOf(Dog())) // type inference failed. Expected type mismatch.

编译器是不会允许这样行为发生。原因就是这样做会导致类型不安全。
我们试想一下,假如这种转换是允许的,那么我们就可以继续添加其它继承了Animal的子类对象,比如:

1
2
val box: Box<Animal> = Box(mutableListOf(Dog())
box.add(Cat())

这样就导致Box<Animal>里面同时保存了DogCat的对象,正如前面提到的,在运行时,调用可能就会抛出ClassCastException,所以这是非类型安全的。

1
2
3
val box = Box(mutableListOf(Dog()))
// val box: Box<Dog> = Box(mutableListOf(Dog()))
val animalBox: Box<Animal> = box // 编译错误

covariance 协变

covariance 协变

但是这种限制太过于严苛了,如果我们只需要从这个box读取元素,而不需要往里面添加,那么这种转换就是类型安全的。具体原因稍后再说。

DogAnimal的子类型,那么Box<Dog>也是Box<Animal>的子类型,这种继承关系就是协变。在Kotlin中,我们需要使用out关键字表示这种关系。

1
2
3
4
class CovarianceBox<out T : Animal>(val elements: MutableList<out T>) {
fun add(t: T) = elements.add(t) //编译错误
fun last(): T = elements.last()
}

基于这种协变关系,我们可以这样调用

1
2
3
val dogs: CovarianceBox<Dog> = CovarianceBox(mutableListOf(Dog(), Dog()))
val animals: CovarianceBox<Animal> = dogs
print(animals.last())

我们注意上面的CovarianceBoxadd方法出现了编译错误,原因就是在协变关系中,泛型参数只能作为输出参数,而不能作为输入参数。因为在拒绝了输入泛型参数的前提下,协变发生的时候,才不会出现强制转化的错误。举个例子:

1
2
3
val dogs: CovarianceBox<Dog> = CovarianceBox(mutableListOf(Dog(), Dog()))
val animals: CovarianceBox<Animal> = dogs
dogs.add(Cat()) // add在这里禁止了

如果CovarianceBox允许add方法,那么box里面就会同时存在多个子类型的实例,这样就会导致类型不安全,所以out修饰的参数化类型,只能在函数的返回值上出现。

不过,这种解决方式也不是万能的,属于杀敌一千,自损八百的战术。因为对于Collection而言,不可能做到任何泛型参数都不会出现在入参的位置上。

1
2
3
4
public interface Collection<out E> : Iterable<E> {
public operator fun contains(element: @UnsafeVariance E): Boolean
public fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
}

所以,针对这种情况,我们知道某些方法其实并不会有添加的操作,可以在入参的位置上加上@UnsafeVariance,以此消除掉编译器的错误。

contravariance 逆变

contravariance 逆变

DogAnimal的子类型,那么Box<Animal>也是Box<Dog>的子类型,这种继承关系就是逆变。在Kotlin中,我们需要使用in关键字表示这种关系。

1
2
3
4
class ContravarianceBox<in T>(val elements: MutableList<in T>) {
fun add(t: T) = elements.add(t)
fun first(): T = elements.first() // 编译错误
}

基于这种逆变关系,我们可以这样调用

1
2
3
val animals = ContravarianceBox(mutableListOf(Animal()))
val dogs: ContravarianceBox<Dog> = animals
dogs.add(Dog())

这个时候,类型始终是安全的。但是我们也注意到ContravarianceBoxfirst方法出现了编译错误,原因就是在逆变关系中,泛型参数只能作为输入参数,而不能作为输出参数。在拒绝了输出参数的前提下,逆变发生的时候,才不会出现强制转换的错误。

1
2
3
4
val animals = ContravarianceBox(mutableListOf(Animal()))
val dogs: ContravarianceBox<Dog> = animals
dogs.add(Dog())
val dog: Dog = dogs.first() // 编译错误

reification 变现

1
reify is To convert mentally into a thing; to materialize.

Kotlin中的Reification的实现使用的是inline模式,就是在编译期间将类型进行原地替换。

1
2
3
4
// 定义
inline fun <reified T : Any> loggerFor(): Logger = LoggerFactory.getLogger(T::class.java)
// 使用
private val logger = loggerFor<AgreementFactory>()

因此,所以原来调用处的代码会在编译期间展开成如下:

1
private val logger = LoggerFactory.getLogger(AgreementFactory::class.java)

使用reification操作,可以精简掉很多模板代码。

type projection 类型投影

type projection 类型投影

上述过程中,我们看到协变和逆变都是针对可以编辑的类。但是如果遇到已经存在的类,这件事就得运用类型投影技术。拿Class这个类举例:

1
2
val dog = Dog::class.java
val animal: Class<Animal> = dog //编译不通过

Kotlin中的type projection就是为了解决这个问题的。

1
2
val dog = Dog::class.java
val animal: Class<out Animal> = dog

同理,

1
2
val animal = Animal::class.java
val dog: Class<in Dog> = animal

我们来看一个真实的场景

1
2
3
4
5
6
7
val agreementClass: Class<RentalAgreement> = RentalAgreement::class.java

private val virtualTable = mapOf(RentalPayload.type to RentalAgreement::class.java)
private fun dispatch(type: String): Class<out Agreement<Payload>> {
return virtualTable[type]
?: throw RuntimeException("No suitable Agreement of this type found, please check your type: $type")
}

只有这样,我们才能将具体的Class<RentalAgreement>投射到Class<out Agreement<Payload>>父类型之上,后续通过某种方式,实例化出RentalAgreement的实例,其继承自Agreement<Payload>

泛型编程的思考

过程式代码 vs. 面向对象
Bob 大叔的 Clean Code 一书的第六章《对象和数据结构》中提到了一个很有意思的现象:数据、对象的反对称性。在这里,数据结构暴露数据,没有提供有意义的函数;对象把数据隐藏起来,暴露操作数据的函数。

过程式代码会基于数据结构进行操作。例如:首先会定义好数据结构Square, CircleTriangle,然后统一在area(shape: Any)的函数中求shape数据的面积,如:

1
2
3
4
5
6
fun area(shape: Any): Double {
return when(shape) {
is Square -> return shape.side * shape.side
else -> 0.0
}
}

而面向对象拥趸一定会嗤之以鼻——显然应该抽象出一个shape类包含area方法,让其它的形状类继承。如:

1
2
3
4
5
6
7
8
9
interface Shape {
fun area(): Double
}

class Square(val side: Double) : Shape {
override fun area(): Double {
return side * side
}
}

在添加新的形状的要求下,面向对象的代码是优于过程式的,因为面向对象对类型的扩展开放了。而过程式代码却不得不修改原来area方法的实现。

但是,如果此时需要添加一个求周长primeter的函数。相对于面向对象代码,过程式代码由于无需修改原来的实现,反而更加容易扩展。反观面向对象的代码,在接口Shape中添加一个primeter会导致所有的子类都得发生修改。

这就是数据和类型的反对称性。在变化方向不同的时候,它们面临的阻力也是不一样的。

隔离阻抗
我们既想要过程式对方法扩展的优点,又执着面向对象自然的类型扩展的好处,该怎么办呢?可以考虑结合起来使用。

这样的结合不是说原有的双向阻力消失了,而是在不同的层次上应用各自的优点。也就是说,Shape需要求面积、周长,同时也要支持类型扩展,这种要求之下,基本不可能调解出一种符合开闭原则的方案。不过,如果对于所有Shape类,都需要统一进行某些操作,例如:集合的排序,过滤等等。那么合并两者的好处就变得可行了。

泛型补充
基于最先分析的通过继承的方式进行泛型编程的缺点:

  1. 太多强制转换
  2. 非类型安全。
    恰当地引入了泛型T,以期编译期的占位和运行时的替换。

泛型限定
不过没有限定的泛型大部分情况下是没有用处的,因为无限的抽象没有意义,所以需要更加精准的泛型限定。

依赖倒置
在我们做完这一切以后,会惊喜地发现依赖倒置(DIP)原则贯穿始终。不论是继承体系,还是改善之后的泛型继承体系。它们秉持的原则就是在编译期,始终朝着稳定、抽象的方向移动,而且不断在易变、具体的方向延迟决策,直到运行时方能确定。

书籍推荐

书籍推荐

脑图

知识梳理


参考链接
泛型 一个会写诗的程序员

Elixir

说服自己

学习新的编程语言的最终目的是解决实际问题。掌握编程语言的过程,在某种程度上近似学习一种新的工程实践。不仅解决问题固然可乐,学习的过程也同样充满了新鲜感,不过需要谨防的是新鲜感带来的胜任力错觉。

胜任力错觉指的是反复接触新东西,发现不用花费什么气力就理解了其中所有的内容。说的简单点,就是自以为是。这种胜任力错觉导致最常见的后果是以为掌握了某种技能,真正开始解决问题时,要么是半天摸不着头绪,要么就是处处掣肘。所以我始终相信,阅读是一码事,理解是一码事,掌握还是另一码事,所谓一码归一码,大抵就是这么回事。

以终为始,方得始终。老子(真·老子,非我)也说,慎终如始,则无败事。这里的“终”就是目标,在软件工程中,有一种实践很好得反映了这种做事方式——测试驱动开发。借我司的一位牛人的原话:看一个人会不会测试驱动开发,不是看他的测试写得好不好,而是要看他是不是始终从测试出发去解决问题。脑子里条件反射的就是测试该怎么测?这种才是测试驱动开发的实质。

学习,说白了就是一个不会到会的过程,这里头最难的是学会了什么?在学习方法上,我们很多时候喜欢遵循前人的套路,美其名曰知识体系化。我承认体系是前人经验和群体智慧的积累,但是学习体系不代表你具备形成体系的能力,就像你学习了著名开发框架(Spring or Rails)也不会说你能开发这套框架一样。学习的关键还是发散、收敛和再发散、再收敛的渐进过程,感性的定性分析到理性的定量分析,在不断丰富和修正认知,处处用实践检验认知。这种过程坚持下来,得到就不单单是知识,可能是元知识(方法论)或者智慧。

看书抄代码是个学习的好方法,不过书中的例子一般都被加工(简化)过,我们很容易陷入套路中,谨记胜任力陷阱。比较推荐的方式,自己认准一段有用的程序,反复练习(也可以每次增加些体系化的功能)直到娴熟。在接触新语言时,不去看一套完整的语言体系,而是事先把这段程序可能用到的基本类型、数据结构、流程控制结构、模块化和功能组件列出来,然后去找它们在这门语言中对应的实现。

有目的地试错

我常用的练手程序叫tree,功能是list contents of directories in a tree-like format. 这个程序需要用到的基本构件有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
基本类型(basic type)
1. str

数据结构(data structure)
1. list
2. map

流程控制结构(control flow structure)
1. if, else
2. recursion

模块化(modulize)
1. function
2. module/namspace/package

功能组件(function components)
1. IO
2. File
3. Path

分类清晰之后,对应找起来很方便,有的基本不用找,经验足矣。现在的编程语言基本都有repl,多尝试几遍就有了感性认识。我说的很轻松,但是如果不去尝试,一样会难住。Elixir中有iex命令作为repl,而且这门语言深受Clojure的影响,尤其是文档和例子方面很充足,对于初学者再友好不过。

换种思维

在编写tree的过程中,我会时不时停下来思考Elixir在某个功能点上应该怎么用才好?因为历史上,把Java的代码写成C风格的人不在少数,这足以让人警惕。再说,学会用新语言的思维方式编程是我初始的目的之一。

这里举个例子,map的key使用哪种基本类型会比较合适?Clojure中有keyword,如{:name "clojure"},而Python中并没有这样的数据类型,我只好使用{'name': "python"},那么Elixir呢?它推荐的是atom/symbol,%{:name => "elixir"} #or %{name: "elixir"}

遇到需要join path的时候,凭借原来的经验,我会去寻找Path模块。具体可以去问谷歌,也可以问repl

1
2
3
4
iex<1> h Path.join
or
iex<1> Path.join <TAB> #用tab键
join/1 join/2

看到join/1 join/2的时候,我有些许迷茫,但是很快就变成了欣喜。我们知道,在动态类型语言中,arity指的是方法参数的个数,这里的1和2其实表明的就是join有两个重载的方法,分别接受一个参数和两个参数。更进一步,arity是方法(函数)实现静态多态的依据之一。再进一步,多态是函数的特性,而非OO中固化下来的概念——类的特性。

组织代码

上面的验证只需要repl就足够了。但是,真正编写还是得有组织和结构。软件工程中,控制复杂度(复杂度从来不会被消除)的基本法则就是模块化。这就引出了module和function,还有对模块可见性(private, public etc.)的修饰。

1
2
3
4
5
defmodule Tree do
defp tree_format(parent_dir, dir_name) do
%{:name => dir_name, :children => []}
end
end

defp定义了一个私有的方法tree_format,它是用来格式化目录的。目录结构是树形结构,所以很容易递归实现。

1
2
3
4
5
6
7
8
9
10
11
defp children(path) do
if (path |> File.dir?) do
File.ls!(path) |> Enum.map(fn f -> tree_format(path, f) end)
else
[]
end
end

defp tree_format(parent_dir \\ ".", dir_name) do
%{:name => dir_name, :children => Path.join(parent_dir, dir_name) |> children}
end

在利用递归的过程中,我使用File.ls!(查文档,注意!号)列出子目录,然后递归地格式化。这些都比较好理解,不过这里其实出现了两个新的玩意(当然也不是一蹴而就的,认识之后才重构成这样)。一个是\\ ".",还有一个是|>。第一个比较容易猜,叫做默认参数(default arguments);第二个有Clojure基础的也手到擒来,叫做管道操作符(pipe operator),用来将左边表达式的结果传入右边方法的首个参数。这里就是children(path)path.

结构,解构

完成目录结构的格式化,接下来需要做的是渲染这组树状的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
defp decorate(is_last?, [parent | children]) do
prefix_first = (if (is_last?), do: "└── ", else: "├── ")
prefix_rest = (if (is_last?), do: " ", else: "│ ")
[prefix_first <> parent | children |> Enum.map(fn child -> prefix_rest <> child end)]
end

defp render_tree(%{name: dir_name, children: children}) do
[dir_name
| children
|> Enum.with_index(1)
|> Enum.map(fn {child, index} -> decorate(length(children) == index, render_tree(child)) end)
|> Enum.flat_map(fn x -> x end)]
end

到这里,我学到的是参数解构(arguments destructing),map-indexed的新实现,字符串的拼接(string concatenation)还有列表元素的前置操作。

Elixir和所有函数式编程语言一样,具备强大的模式匹配(Pattern matching)的功能,参数解构其实就是其中的一个应用场景。

1
2
3
4
5
6
%{name: dir_name, children: children}
matching
%{:name => ".", :children => ["tree.exs"]}
# ->
dir_name == "."
children == ["tree.exs"]

渲染的过程也是递归的。最终返回的是一个加上分支标识前缀的列表

1
[dir_name | children]

这是一种将dir_name前置到children列表头部,形成新列表的做法。和Clojure(绝大数Lisp)中的(cons dir_name children)类似。

操作符|除了可以前置列表元素,递归解构也是一把好手。

1
2
3
defp decorate(is_last?, [parent | children]) do
...
end

参数列表中的[parent | children],解构出了列表的head和rest,这对于递归简直就是福音。

在添加前缀的步骤[prefix_first <> parent...]中,经验里字符串的拼接常用符号+不起作用了,换成了<>,这个是靠试错得出来的。

除了说到的这部分内容,我还运用了Enum.map, Enum.with_index, Enum.flat_map等函数式语言的标配。这些零散的知识点,可以添加到基本构件中,以便持续改进。

入口

程序要执行,就需要一个入口。每次我都会猜猜argv会在哪里出现呢?是sys(Python),os(Go),还是process(Node.js),这回又猜错了,Elixir管这个叫做System.

1
2
3
4
5
6
7
  def main([dir | _]) do
dir |> tree_format |> render_tree |> Enum.join("\n") |> IO.puts
end
# ---
Tree.main(System.argv)
# ---
$ elixir tree.exs .

重构

这里重构的目的是让程序更加贴近Elixir的表达习惯,那么哪里不是很符合Elixir风格呢?我注意到了if...else,可以考虑模式匹配实现多态。

1
2
3
4
5
6
7
defp children(path) do
if (path |> File.dir?) do
File.ls!(path) |> Enum.map(fn f -> tree_format(path, f) end)
else
[]
end
end

File.ls!中的!表示如果指定目录有问题,函数会抛出error或者异常。然而,Elixir还给出了一个File.ls方法,即便出错,也不会有抛出的动作,而是返回{:error, ...}的元组,至于正常结果,则是{:ok, ...}. 这恰恰可以使用模式匹配做动态分派了。

1
2
3
4
5
6
7
8
9
10
11
defp children(parent) do
children(parent |> File.ls, parent)
end

defp children({:error, _}, parent) do
[]
end

defp children({:ok, sub_dir}, parent) do
sub_dir |> Enum.map(fn child -> tree_format(parent, child) end)
end

一旦 children(parent |> File.ls, parent)中的parent不是目录,File.ls返回的就会是{:error, ...}元组,它会被分派到对应的方法上,这里直接返回一个空的列表。反之,我们就可以拿到解构之后的子目录sub_dir进行交互递归,实现全部子目录的格式化。

小结

在学习Elixir的过程中我收获了很多乐趣,不过,这离掌握Elixir还有很远的距离。我曾经看过一部科幻电影“降临”,剧情受到了萨丕尔-沃夫假说(语言相对性原理)的影响,这个假说提到:人类的思考模式受到其使用语言的影响,因而对同一事物时可能会有不同的看法。既然如此,那么自然语言也好,编程语言也罢,如果能换种思维方式解决同一种问题,说不定能收获些奇奇怪怪的东西,编程之路,道阻且长,开心就好。 – 2018-06-08


如何高效地学习编程语言
怎样才算学会Python
Elixir
萨丕尔-沃夫假说

Python inside the door

Python 实践基础

起源

假如你已经有了编程基础,那么学习一门新语言的困难点绝对不在语法、语义和风格等代码层面上的,而在于语言范式(OO,FP还是Logic),语言的生态(如:依赖管理和包发布等)和工具(编辑器,编译器或者解释器)这些方面,请参看如何高效地学习编程语言。再假如你已经对各类语言范式都有一定的了解,那么最后的困难之处就是…细节,它是魔鬼。

我相信真正拥抱一门新语言,花在工具和语言生态上的时间一定很多。庞大的社区利用群体智慧构筑的生态圈充满了各种零碎的知识点,这些知识点可能是前人趟过的陷阱(Common Gotchas),基于局部共识经由经典项目实践过之后的约定(Convention)和惯用法(Idioms),也可能是总结出的概念模式(Pattern),甚至是审美(Aesthetic)和禅(Zen)或道(Dao)。这些知识点作用到了工具和语言生态之中,意味着你需要使用合适工具、遵循生态的玩法才能从中受益。

工具

工欲善其事必先利其器,对于程序员而言,这个器是编辑器…吗?Emacs, Vim, VS Code or PyCharm?

解释器

当然不是,这个器应当是让你能立马运行程序并立刻看到结果的工具,在Python的上下文中,它是Python的解释器。一般情况下,我们会选择最新版的解释器或者编译器,但是Python有一点点例外,因为Python3和2并不兼容,那么该选择哪个版本呢?寻找这类问题的答案其实就是融入Python社区的过程。幸运的是,社区出版了一本书 *The Hitchhiker’s Guide to Python*,里面诚恳地给出了建议。所以不出意外,Python3是比较合适的选择。

因为Python安装起来很简单,我们跳过…吧?不过,大侠留步,你可知道Python其实只是一个语言标准,它的实现程序不止一个,其中官方的实现是CPython,还有Jython和IronPython等。不过,CPython作为使用最为广泛的解释器当然是开发之首选。

1
2
3
4
5
6
$ python3
Python 3.6.5 (default, Jun 17 2018, 12:13:06)
[GCC 4.2.1 Compatible Apple LLVM 9.1.0 (clang-902.0.39.2)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> print("hello world")
hello world

编辑器

虽然面向REPL编程(Repl-Oriented Programming)是一种比单元测试的反馈速度更快的编程方式,但是在REPL中编写应用程序并不合适,不合适的地方表现在代码不易组织(分模块)和代码没法记录(存盘)。所以我们需要可以编辑的源代码、目录和其它相关文件,这个时候就需要挑选趁手的编辑器。

神之编辑器Emacs中内置了python-mode,如果已经是Emacs用户,这款编辑器当是写Python的不二之选。编辑器之神的Vim排第二,如果你比较喜欢折腾Vim8.0的插件,或者想自己构建NeoVim的话。其它的编辑器,我不知道,不想用。不过PyCharm是Jetbrains家的IDE,靠谱。

有功夫在Terminal中装一个emacsclient,然后下载一个oh-my-zsh的插件emacsclient,就可以很愉悦地在Terminal中使用Emacs编辑文件了。

1
2
3
4
5
6
7
8
9
10
11
12
$ te hello_world.py # te: aliased to /Users/qianyan/.oh-my-zsh/plugins/emacs/emacsclient.sh -nw

"""
hello_world.py
Ctrl+x+c 退出emacs :)
"""
print("hello world")

$ python3 hello_world.py
hello world
$ python3 -m hello_world #注意没有.py的后缀
hello world

生态

基本工具比较好把握,但是何时选择什么工具做什么样的事情就不好拿捏了,而且如何把事情做成Pythonic的模样也是对经验和能力的考验。

如果我们不是编程小白的话,就需要充分利用迁移学习的能力了。学习的最好方法就是解决问题。不得不承认,在动手实践的过程,时间走得是最快的,在同一件事上花的时间越多也就越熟悉。

我们尝试用Python编写一个tree命令行(Command-Line Application),顾名思义,打印目录层级结构的程序,详细描述参看这篇命令行中 tree 的多重实现

测试模块

怎么写测试呢?多年养成的TDD习惯让我首先想要了解什么是Python中常用的测试工具。答案不难寻找,unittest是Python内置的测试模块,而pytest是比unittest更简洁和强大的选择,所以我选择后者。

这个程序的测试我使用pytest,但是它并不是所有项目测试的唯一选择,所以最好能局部安装,尤其是限制在当前工程目录里。搜索查找的结果是,Python3内置的虚拟环境(Virtual Environment)模块可以做到这点。


虚拟环境
在当前创建venv目录(python3 -m venv venv),然后用tree命令查看该目录的结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ python3 -m venv venv
$ tree -L 4 venv
venv
├── bin
│   ├── activate
│   ├── activate.csh
│   ├── activate.fish
│   ├── easy_install
│   ├── easy_install-3.6
│   ├── pip
│   ├── pip3
│   ├── pip3.6
│   ├── python -> python3
│   └── python3 -> /usr/local/bin/python3
├── include
├── lib
│   └── python3.6
│   └── site-packages
│   ├── __pycache__
│   ├── easy_install.py
│   ├── pip
│   ├── pip-9.0.3.dist-info
│   ├── pkg_resources
│   ├── setuptools
│   └── setuptools-39.0.1.dist-info
└── pyvenv.cfg

进入虚拟环境,然后使用pip3安装pytest测试模块,会发现venv目录多了些东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$  . venv/bin/activate
venv ❯ pip3 install pytest
Collecting pytest
...

$ tree -L 4 venv
venv
├── bin
│   ├── py.test
│   ├── pytest
├── include
├── lib
│   └── python3.6
│   └── site-packages
│   ├── __pycache__
│   ├── _pytest
│   ├── atomicwrites-1.1.5.dist-info
│   ├── attr
│   ├── attrs-18.1.0.dist-info
│   ├── more_itertools
│   ├── more_itertools-4.2.0.dist-info
│   ├── pluggy
│   ├── pluggy-0.6.0.dist-info
│   ├── py
│   ├── py-1.5.3.dist-info
│   ├── pytest-3.6.2.dist-info
│   ├── pytest.py
│   ├── six-1.11.0.dist-info
│   └── six.py

此时,虚拟环境会在PATH变量中前置./bin目录,所以可以直接使用pytest命令进行测试。根据约定,测试文件的名称必须以test_开头,如test_pytree.py,测试方法也必须如此,如test_fix_me。遵循约定编写一个注定失败的测试如下:

1
2
3
4
5
6
7
8
9
10
11
12
"""
test_pytree.py
"""
def test_fix_me():
assert 1 == 0

$ pytest
...
def test_fix_me():
> assert 1 == 0
E assert 1 == 0
test_pytree.py:5: AssertionError

测试失败了,说明测试工具的打开方式是正确的。在进入测试、实现和重构(红-绿-黄)的心流状态之前,我们需要考虑测试和实现代码该放在哪里比较合适。

假设我们会把pytree作为应用程序分发出去供别人下载使用,那么标准的目录结构和构建脚本是必不可少的,Python自然有自己的一套解决方案。

目录结构

Packaging Python Projects的指导下,我们略作调整,创建和源代码平级的测试目录(tests),得到的完整目录如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.
├── CHANGES
├── LICENSE
├── README.md
├── docs
├── pytree
│   ├── __init__.py
│   ├── __version__.py
│   ├── cli.py
│   └── core.py
├── setup.cfg
├── setup.py
├── tests
│   ├── fixtures
│   └── test_pytree.py
└── venv

这样的目录结构不仅可以清晰地模块化,隔离测试和实现,提供使用指导和版本更新记录,还可以很方便地做到包依赖管理和分发,这得归功于setup.py,它是Python项目中事实标准(de facto standard)上的依赖和构建脚本,pytree下的setup.py内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# setup.py
# -*- coding: utf-8 -*-

from setuptools import setup, find_packages
from codecs import open
import os

here = os.path.abspath(os.path.dirname(__file__))

about = {}
with open(os.path.join(here, 'pytree', '__version__.py'), 'r', 'utf-8') as f:
exec(f.read(), about)

with open('README.md') as f:
readme = f.read()

with open('LICENSE') as f:
license = f.read()

setup(
name='pytree',
version=about['__version__'],
description='list contents of directories in a tree-like format.',
long_description=readme,
author='Yan Qian',
author_email='qianyan.lambda@gmail.com',
url='https://github.com/qianyan/pytree',
license=license,
packages=find_packages(exclude=('tests', 'docs')),
classifiers=(
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
),
setup_requires=['pytest-runner'],
tests_require=['pytest'],
entry_points = {
'console_scripts': [
'pytree = pytree.cli:main'
]
},
install_requires=[]
)

setup.py能帮助我们解决测试中依赖模块的问题,这样我们把pytree作为一个package引入到测试代码中。

1
2
3
4
5
6
7
8
9
10
11
12
13
venv ❯ python3
Python 3.6.5 (default, Jun 17 2018, 12:13:06)
[GCC 4.2.1 Compatible Apple LLVM 9.1.0 (clang-902.0.39.2)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys, pprint
>>> pprint.pprint(sys.path)
['',
'/usr/local/Cellar/python/3.6.5_1/Frameworks/Python.framework/Versions/3.6/lib/python36.zip',
'/usr/local/Cellar/python/3.6.5_1/Frameworks/Python.framework/Versions/3.6/lib/python3.6',
'/usr/local/Cellar/python/3.6.5_1/Frameworks/Python.framework/Versions/3.6/lib/python3.6/lib-dynload',
'/Users/qianyan/Projects/personal/public/pytree/venv/lib/python3.6/site-packages',
'/Users/qianyan/Projects/personal/public/pytree/venv/lib/python3.6/site-packages/docopt-0.6.2-py3.6.egg',
'/Users/qianyan/Projects/personal/public/pytree']

然后运行pytest或者python3 setup.py pytest,此时pytest会把.pytree/tests前置到PATH变量中,验证如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
# test_pytree.py
import sys

def test_path():
assert sys.path == ''

venv ❯ pytest
-> AssertionError: assert ['/Users/qianyan/Projects/personal/public/pytree/tests',
'/Users/qianyan/Projects/personal/public/pytree/venv/bin', ...] == ''

venv ❯ python3 setup.py pytest
-> AssertionError: assert ['/Users/qianyan/Projects/personal/public/pytree/tests',
'/Users/qianyan/Projects/personal/public/pytree', ...] == ''

这里python3 setup.py pytest可以通过setup.cfg设置别名(alias):

1
2
3
# setup.cfg
[aliases]
test=pytest

python3 setup.py test的效果和前面的命令等同。

使用TDD的方式实现了pytree核心的功能(源代码),然后考虑如何把它变成真正的命令行程序。首先要解决的问题是如何以用户友好的方式显示需要哪些传入参数,我们期待pytree -h能提供一些帮助信息,为了不重复造轮子,挑选现成的Option解析库比较轻松。Python内置的argparse已经足够用了,不过docopt值得尝试。

依赖管理

setup.py提供了依赖管理功能,声明依赖及其版本号。

1
2
3
# setup.py
...
install_requires=[docopt==0.6.2]

然后运行python3 setup.py develop安装。就绪之后,编写cli.py作为命令行程序的入口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin env python3
"""list contents of directories in a tree-like format.
Usage:
pytree <dir>
pytree -h | --help | --version
"""
import pytree.core as pytree
import pytree.__version__ as version

def main():
from docopt import docopt
arguments = docopt(__doc__, version=version.__version__)
dir_name = arguments['<dir>']
print('\n'.join(pytree.render_tree(pytree.tree_format('', dir_name))))

if __name__ == "__main__":
main()

通过打印help信息的方式验证是否符合预期:

1
2
3
4
5
$ python3 pytree/cli.py --help
list contents of directories in a tree-like format.
Usage:
pytree <dir>
pytree -h | --help | --version

当然理想的结果是直接可以运行pytree --help,setup.py的console_scripts刚好派上用场。

1
2
3
4
5
6
# setup.py
entry_points = {
'console_scripts': [
'pytree = pytree.cli:main' #以pytree作为命令行程序的调用名
]
}

此时查看which pytree显示/Users/qianyan/Projects/personal/public/pytree/venv/bin/pytree,说明pytree已经在路径变量当中,可以直接执行:

1
2
3
$ pytree tests/fixtures
tests/fixtures
└── child

完成了命令行程序并通过测试,我们尝试发布到测试仓库(TestPyPI)供其他人下载使用。

包发布

依照文档描述,先去TestPyPI注册用户,本地打包成发行版,然后安装twine工具发布。

1
2
3
4
5
6
7
8
$ python3 -m pip install --upgrade setuptools wheel
$ python3 setup.py sdist bdist_wheel
$ pytree dist # pytree查看dist目录
dist
├── pytree-1.0.2-py3-none-any.whl
└── pytree-1.0.2.tar.gz
$ python3 -m pip install --upgrade twine
$ twine upload --repository-url https://test.pypi.org/legacy/ dist/* #or twine upload --repository testpypi dist/* 如果你配置了~/.pypirc

上传成功需要一段时间,等待服务完成同步才可以下载,我们在另一个虚拟环境中进行验证:

1
2
3
4
5
6
7
8
$ python3 -m venv test
$ . test/bin/activate
test > python3 -m pip install --index-url https://test.pypi.org/simple/ pytree==1.0.2
test > ls venv/lib/python3.6/site-packages/
...
pytree
pytree-1.0.2.dist-info
...

确保site-packages目录下有这两个目录:pytree和pytree-1.0.2.dist-info,然后我们就可以完成最后的验证阶段了,如下:

1
2
3
test > pytree tests/fixtures
tests/fixtures
└── child

这里版本号之所以是1.0.2,是因为已经上传过了0.0.1, 1.0.0, 1.0.1 等版本,TestPyPI不允许同名同版本的文件重复上传,即使删除原来的文件也不行。前面的版本都有一定的错误,错误的根因在于find_packages以及package_dir的配置项文档说明很模糊,而且只有到上传到TestPyPI然后下载下来,才能验证出来,这种缓慢的反馈是Python的应该诟病的地方。


注意
find_package()也是一个深坑,第一个参数如果写成find_packages('pytree', exclude=...),那么pytree下的所有Python文件都会被忽略。原因是pytree已经是package,所以不应该让setup去这个目录找其他的packages.

这个package_dir也是如此,我们如果设置package_dir={'': 'pytree'},setup.py就会将/Users/qianyan/Projects/personal/public/pytree/pytree前置到PATH中,这会导致console_scripts': ['pytree = pytree.cli:main']抛出错误 ModuleNotFoundError: no module named ‘pytree’,究其原因是pytree/pytree导致setup尝试在pytree/pytree这个package里头找自己(pytree),自然找不到。但是如果改成console_scripts': ['pytree = cli:main'],因为cli在pytree/pytree底下,所以就能成功执行。当然这是一种错误的写法。

如果遇到了 ModuleNotFoundError: no module named ‘pytree’ 的错误
,最好的方式就是import sys, pprint然后pprint.pprint(sys.path),很容易发现Python运行时的执行路径,这有助于排查潜在的配置错误。

资料

  1. Pytree Source Code
  2. The Hitchhiker’s Guide to Python
  3. Emacs python-mode
  4. Python Test Tool
  5. Packaging Python Projects

关于环境和数据类型

简介

  1. Emacs集成 Idris 开发环境
  2. Idris repl 使用说明
  3. Idris 代数类型定义

1. Emacs 安装 idris-mode

1
2
3
4
5
6
7
(use-package idris-mode
:mode (("\\.idr$" . idris-mode)
("\\.lidr$" . idris-mode))
:ensure t
:defer t)

(provide 'init-idris)

emacs 打开任何以*.idr*.lidr作为后缀的文件,都可以启用idris-mode.
另外,使用C-c C-l可以在*idris-repl*中加载当前文件并启用 type check 进行检查,出现的错误会打印在*idris-notes* buffer中。

注意
关于 IO 的调用问题,经典 Hello World 程序:

1
2
3
4
module Main

main : IO ()
main = putStrLn "Hello World"

当需要在repl中调用 main 方法时,需要通过:x main 执行,才能看到执行结果,Hello World 会显示在*idris-process* buffer 中。原因是 repl 会返回一个 IO action,这个 IO action 只会在 idris 之外 hook 的 terminal 中才会执行。https://github.com/idris-lang/Idris-dev/issues/3152

1
:x  <expr>  Execute IO actions resulting from an expression using the interpreter

2. 自定义数据类型

我们先定义一下自然数:自然数就是从0开始,后面的数都比前一个自然数多1的数列。我们从小知道的自然数0, 1, 2,…,100,… 看上去只是一系列割裂开的一组符号,但是事实上,数列本身必然存在一些属性,数与数之间必然存在规律。

基于前面提到的自然数的属性,我们定义自然数如下

1
data Natural = Z | S Natural

读作:自然数要么是Z(零),要么是自然数的后继(S)

2.1 定义加法

接下来,我们定义自然数的加法运算

1
2
3
plus : Natural -> Natural -> Natural
plus Z m = m -- 模式1
plus (S n) m = S (plus n m) -- 模式2

首先定义出了 plus 函数的类型,它是接收两个自然数,然后返回一个自然数的函数,这里使用了柯里化的表现方式。
plus Z m = m 表示任何自然数加上零,都得自然数本身;
plus (S n) m = S (plus n m) 表示任何两个自然数相加,都等于其中一个自然数的前趋和另一个自然相加结果的后继。这句话说起来比较绕,但是只要展开之后就比较容易理解了。

考察1 + 1 = 2,可以表达如下:

1
2
3
4
(S Z) # 1 是 0 的后继
plus (S Z) (S Z) -- 1 + 1
S (plus Z (S Z)) -- 根据模式2展开上面的式子
(S (S Z)) -- 根据模式1展开上面的式子

(S (S Z)) 就是自然数2

2.2 定义乘法

1
2
3
mult : Natural -> Natural -> Natural
mult Z m = Z
mult (S n) m = plus m (mult n m)

mult Z m = Z表示任何自然数乘以零,都得零;
mult (S n) m = plus m (mult n m)表示任何两个自然相乘,都等于其中一个自然数和另一个自然数的前趋相乘结果再加上这个自然数。


学习资料
[1] Idris mode
[2] Learn idris
[3] Idris docs

太长不读篇

《技术简史》原著*Ruling the wave, From the Compass to the Internet, a History of Business and Politics along the Technological Frontier)*。依书中的视角,从15世纪的地理大发现到21世纪的网络音乐,每一次技术的创新和商业发展都大致遵循4个阶段规律:

  1. 创新
  2. 市场化
  3. 创造性的混乱
  4. 制定规则

制定规则者为王就是本书的核心观点。引用1993年获得诺贝尔经济学奖的Douglass North的研究:市场刚出现时,稍大的组织可以通过行会或协会来规范市场的交易,但这些早期的市场如果想要最终发展成为高效的大型企业的话,就需要国家介入,制定各种规章制度以保证贸易顺畅。用他的话来说就是,巩固市场所需的财产所有权必须由政治制度和司法体制通过保证签订契约的成本最小化来落实。

不过,这里的问题是政府就能保证签订契约的成本最小化么?如果出现一种新的技术,它可以让这个成本小于政府的监管和司法投入,那么它就不仅是一种技术的革新,同时是对技术发展既定规律的革新。答案是区块链?没有产权和交易的规则,市场一定不会发展。这里的产权对应区块链应用中资产转账,而交易规则对应的是智能合约。那么答案可能真是区块链了。

作者简介

Debora. spa,女,哈佛大学博士,哈佛商学院教授。著有《The baby business》和《Ruling the waves》(译本:技术简史)。这本《Ruling the waves》最早出版于2001年,中文译本于2017年4月由中信出版社出版。主要描述了从15世纪的大航海到21世纪的互联网时代,创新技术的发展规律,得出制定规则者为王的结论。

配合《维多利亚时代的互联网》一起读,对电报带来的社会变革会更有切身感受,通过时间线串联起来理解效果更佳。

历史在一遍遍重演

“没文化,真可怕!”
回到中世纪那黑暗的1000年间,教会的僧侣掌握着知识的生产和传播,未曾开化的愚民既没有能力也没有渠道获取知识,其中就包括圣经抄本及其解释权。这些愚民只能听从教会宣扬的神祗和信仰,怀疑者统统被审判为异教徒和魔女,火烧异教徒和魔女狩猎甚嚣尘上,一切都是没有文化的恶果。但是压迫必然遭遇反抗,活字印刷术的出现让知识出版变得开放和自由并且廉价,知识的生产和传播开始规模化,人们可以直接获取知识而不再依赖教会,解读圣经的权利得以回归,随之稀释的是教会的控制权。但是教会也不会坐以待毙,他们建立自己的天主教印刷工厂推动反宗教改革,印制大量《圣经》和核心书刊,并且知道如何争取有读写能力的追随者,进而掌握了知识的核心传播形式,其统治地位并没有被动摇。虽然如此,但是世界的规则已经改变,权力发生了转移。

印刷术将物理世界中这些以前需要手抄的竞争性资源变得廉价,让知识持久化下来,打破了时间的侵蚀,但是知识的传播却还是受限于距离,行进缓慢。每当这时,总有英雄出现。19世纪,电报的发明以及历经坎坷的商业化进程,彻底改变信息传播的形式,加速了全球的信息互联。再加上19世纪末,电话和无线电通讯的发明,整个世界宛若突然缩小成一体,信息的传递变得不可思议得快速和廉价。20世纪计算机的发明奠定了20世纪末的互联网诞生的基础,信息传递不仅变得快速,其内容还异彩纷呈。这个时代,没有哪个组织和团体可以垄断信息及其传播形式。

第一次浪潮

15-17世纪的大航海时代,航海技术的大发展开启了一个新的商业世界。
葡萄牙亨利王子,获得“航海家”亨利的称号,1460去世,大航海时代来临。

技术创新
14世纪,欧洲的船运贸易无法远离海岸,受制于变幻莫测的天气和有限的航海技术。威尼斯的商人只敢在亚得里亚海和爱奥尼亚海(地中海)航运,不敢穿越直布罗陀海峡(西班牙和北非摩洛哥)来到北大西洋。英国商人也只敢航行到法国西部的比斯开湾。
西欧

14世纪初,船舶工艺升级,厚重的船让位于轻快的帆船,这些船比原来的更大也更轻。另外,13世纪中期,《图解海航手册》可让航海员可以估算地理位置。15世纪早期,指南针等技术也等到了广泛应用。

先驱
葡萄牙的亨利王子,为了实际的目的——西非的黄金,基督教布道等,沿着非洲的西海岸航行,并且记录下了航海活动的信息收集。在这过程中,改良了地图绘制和造船工艺,让很少的人就能操作很大的船。早期的船只能顺风航行,但是亨利王子的轻快的小船在逆风时也能航行,这样远航的船只也能顺利返航。

亨利王子与1460年去世,新一代的探险者正式开启了大航海的时代。葡萄牙海员率先南下到达非洲的最南端,并且来到了印度洋的西海岸。1484年,哥伦布得到了西班牙国王费迪南的资助,发现了美洲新大陆;1521年,麦哲伦绕过了南美洲最南端,进入太平洋,然后到达亚洲。这证明了世界是圆的。

1569年,比利时的绘图者墨卡托(Mercator)发明了一种新式的地图绘制方法,墨卡托投影,把地球沿着经线分割开然后展开成一个矩形,这样的绘制技巧在精度上有了质的飞跃。普通的航海员也可以远航了。

创新性的混乱
海盗。16-18世纪,伴随着大海航时代的到来,海盗猖獗。主要原因一个是海上并没有很好的法律约束,还有一个最重要的原因是国家在维护自己的权益。这个时代大部分的欧洲国家都在从事私掠活动。给海盗授予掠夺的权利是最经济的打压敌国贸易的政治手段。

大海盗弗朗西斯德雷克(Francis Drake)的故事
弗朗西斯·德雷克

德雷克大半个人生都是海盗。早年间,他在英国精英的支持下,做起了“三角贸易”。从非洲俘获奴隶,然后卖到加勒比海的西班牙殖民者,再从他们手上换取毛皮等货物,然后再带回英国。不过,在一次返回英国的航行中,他的船遭到了来自西班牙军队的袭击死里逃生。复仇的怒火在心里燃烧。自从被英国赋予掠夺权之后,他开始袭击美洲的西班牙殖民地,掠夺海上的丝绸和黄金。1581年,伊丽莎白一世女王授予他爵位。一以贯之的海盗生存法则——“在合适的时候,跳上正确的船”。

制定规则
1701年,英国政府公开绞死了著名的海盗之一的威廉·基德
1721年,当罗杰斯离开拿骚岛,加勒比海最猖狂的“黑胡子”艾华德·蒂奇和“白棉布”杰克也已经绝迹。

1730年,海盗的黄金时代结束了,但是知道1830年才算几乎根除。

为何原本受到国家暗中保护的海盗突然遭到了所有政府的封杀?因为规则改变了。海盗这个团体不再是政府的政治手段,反而成为了问题。1588年,西班牙无敌舰队在和英国的海战中战败,伴随而来的是西班牙在大西洋垄断时代的没落。而且原来的大海航时代的先驱逐渐占领了海洋贸易这些商人,他们为了维护自己的利益,也开始要求政府打击海盗。1856年,欧洲7个国家的代表在巴黎会面,签署了《巴黎宣言》,正式宣布任何海上的私掠行为都被禁止。

电报时代

技术创新
1791年法国的查普兄弟通过铜锅和同步的2倍速时钟传递信息。法国的查普发明了感观电报,1793年,法国建立起第一个观感电报塔。1837年,摩尔斯发明了摩尔斯电码和电报,并将电报推出实验室。

电报先驱
库克,菲尔德,奥莱利,彭德这些电报先驱们看到了电报的商业潜力,然后发展起了新的市场。

创新性混乱
电报市场发展起来之后,遇到了和其它技术一样的问题。最大的问题就是如何合作:电报的线路是分离的,同时使用的code book(代码本)也不同。

如果电报网络互不兼容,那么每条线路的价值都会被降低。电报先驱们开始制定标准和规则。

制定规则
欧洲和美国一开始介入市场的方式不同。欧洲各国都有自己的电报线路和标准,因为当时大部分的电报公司都和政府有关系,为了能够实现国际间的通讯,欧洲大陆共同推行相同的标准和技术规范,这要归功于成立的国际电报联盟。

不过美国恰好相反,政府在早期就退出了电报市场。然后私营企业强强联合,建立起了一套共同遵守的规则。不过市场竞争的本质就是消除竞争,在各自利益的驱使下,短暂建立起的联盟是脆弱。这表现在电报市场就是西部联合公司一家独大,而这实质上就是垄断。最后,政府颁布了公共规则。1910年,美国国会通过一项法案,授予州际商业委员会调查电报收费的权利。1934年,通过通信法案,正式把规范电报业的任务交给了联邦通信委员会。

无线电时代

技术创新
19世纪20年代末,电磁感应让无线电的研究向前一步。1865年,苏格兰数学家麦克斯韦证明了电和光可以以相同的速度在空气中传播。最终在1887年,德国科学家赫兹成功地用实验证明了麦克斯韦用数学说明的现象。

马可尼发明了无线电装置,并成功将它进行了商业化应用。

无线电先驱
马可尼无线电报公司和德国德律风根公司,那时候还只是能够传递电码;1918年美国的RCA(美国无线电公司)诞生,已经可以进行声音广播了。

创新性混乱
1910年左右,传播信号开始互相干扰。1912年4月12日,豪华巨轮”泰坦尼克号“撞到冰山沉没,因为信号阻塞特别严重,导致家属迟迟得不到救援信息。因为这件事,美国政府要求业余无线电操作员需要取得营业执照,而且无线电频谱变为离散状态。

自从广播播放音乐火了之后,人们开始私自搭设电台,同时混乱使用频道的方式开始发生。

制定规则
1927年,美国出台了《无线电法案》,大公司相当统一,无线电行业在这个时候”可能是全美唯一一个全体一致要求管制自身的行业“了。究其缘由,有线电报的所有者和财产所有权是明确的,但是无线电波是无形的,因为没有一个成形的财产所有权分配系统,所以建立频段划分系统除了政府没有人可以胜任。

卫星电视和数字电视时代

1983年,鲁伯特·默多克(Rupert Murdoch)的新闻集团购买了一家英国的卫星电视SATV(卫星电视)并更名为 Sky,他们想在英国的电视市场努力地开创出一片卫星电视的天地。因为卫星电视在后期的维护以及远距离传播上有天然的优势。而且,BBC电视只为政府代言,播放充满精英文化的正经说道题材也确实让民众难以下咽。以丘吉尔为代表的保守党重新掌权(二战期间)之后,开始稳步地将竞争引入电视市场。

英国的无线电视技术的历史久远。早在1926年,一名叫做约翰·贝尔德(John Baird)的企业家,说服了英国广播公司(BBC)发展广播可视图画的技术,进而演化成了BBC电视业务。经历过一段时间的国有垄断之后,由于政策的允许,各大商业独立电视台也开始进入电视市场,并且在市场份额上形成了势均力敌的局面。当然,政府的监管机制从来没有落下,ITA(独立电视局,类似广电总局)负有监管和审核独立电视节目的责任。只不过这个还只是地面上的无线电视广播,压根没有卫星电视什么事儿。

时间来到了1977年,世界无线电管理委员会分配了已知的卫星空间,同时规定参与国可以得到当时广播卫星所有频道中5个频道。英国将其中的2个频道分配给了老牌的BBC,剩下的3个频道通过竞标的方式分配给了BSB(英国卫星广播公司)。Sky公司却在竞标的过程中失败了,但却不一定是坏事。当BSB调用大量精英研发新技术(D-MAC)标准的时候,Sky公司租了位于法国,比利时和德国三国交界处,一个叫做卢森堡大公国的通讯卫星。这个决策的厉害之处是恰逢欧洲委员会规定:对任何广播卫星的制裁只能由来源国发起。Sky公司绕过了英国的法律约束,同时发动和推出了上门推销技能和免费的售后服务,这些举措成功地吸引了客户,并最终帮助Sky公司占领了市场。在这场商业角逐当中,Sky成功扭转局势,打败了主要竞争对手BSB。1990年,两家公司达成协议,合并成一家公司,并取名为BSkyB公司。同时默多克的新闻集团获得了50%的股份,并拥有绝对的控制权。

BSkyB的故事并没有结束,因为有一位创奇的人物还没有登场。

由于商业模式和原来那些独立电视没有太大区别,导致BSkyB没有足够的订单来获得广告收入,再加上合并之后内部摩擦不断,BSkyB在起飞阶段的日子并不好过。于是,默多克私下邀请萨姆·克里泽木(Sam Chisholm)加入了Sky。在那之前,克里木泽就以果敢和成就而闻名。

克里泽木迅速采取了行动。他首先抢占先机控制电视内容,飞去美国和好莱坞的影片公司签约,这其中就包含默多克自己的福克斯公司(21 Century FOX)。非常意外的,他应好莱坞影片公司对产权的保护要求,发展出了一种全新的商业模式——通过加密技术保护产权,并引导顾客为片源付费,从而建立起来一种关联影片提供商和顾客的全新收费模式。除此之外,他也和体育赛事联盟签约,将原来处于公共领域的体育赛事直播转化成了私营方式。虽然这种方式遭到各方质疑和控诉,但是最终还是安然无事,并获得了巨额回报。

其次运用准入控制手段,他联合以色列的Adi Shamir(RSA加密方式中A指的就是他)建立了一家专门为BSkyB公司提供加密服务的NDC(News datacom)公司。同时积极开发包含加解密、内容管理功能的机顶盒。在稳固新建立的商业模式的同时,还通过市场份额的优势,迫使其它内容供应商加入自己的系统。这种做法巩固了BSkyB的生态环境,也为后来其它竞争手对其垄断的控诉提供了证据。

回顾Sky公司的一路辉煌,就会发现挺符合辩证法中的否定之否定的规律。卫星电视技术,因为其创新的特征,让Sky公司绕过了英国的法律和规则,以一种“海盗式”的手段,打开了顽固的英国电视市场。当竞争者开始用反垄断法为自己发言的时候,它又用自创的商业模式——付费电视,把自己隐藏到整个电视市场这个大背景下,躲过了制裁。貌似一切规则都失效了——这就是创新的力量。但是中国有句老话“成也萧何败也萧何”,技术界从来不缺乏新闻。

数字电视时代扑面而来。由于数字信号比模拟信号具有可压缩,可降噪的优势,很快得到政府的重视。1996年,英国推出了《广播法》并统一建立6个新的数字频道。在过渡到数字电视的阶段,英国政府提供了很多对私营企业十分友好的条件。一开始混有BSkyB血统的BDB(英国数字广播电视,后改名为ONdigital)在政治和商业利益的驱使下,将BSkyB从联盟中踢出,并获得了政府划拨的近一半的地面数字波段。

BDB因为有了数字许可证,便开始和BSkyB展开了合作和竞争。一方面BSkyB依赖于BDB的许可证;另一方面,BDB又依赖BSkyB提供的影片服务。这期间BDB更名为ONdigital。1999年末,BSkyB和ONdigital公司成为了英国数字电视市场的两大巨头。经过这轮技术洗牌之后,政治方向也开始朝着不利于BSkyB公司的方向倾斜,表现在BSkyB对足球俱乐部曼彻斯特连队接管被禁止。另外,政府颁布了新一轮的电视标准中,要求提供一种标准的接口,而BSkyB公司从未使用过,而且这相当于打破了BSkyB的生态闭环,也意味着它不得不和其它电视公司展开公开竞争。

这一波数字技术的截胡操作,让BSkyB失去了垄断的地位。否定之否定同样作用到了BSkyB自己的身上。不管如何,电视技术还在持续发展中。

密码朋克

密码朋克(crypherpunk)指的是一帮倡导使用强加密技术保护个人隐私的活动家,他们的敌人是企图剥夺民众使用加密技术的政府。触发密码朋克组织形成的导火索是1993年美国政府企图在所有的计算机和手机植入一种Clipper芯片,这种芯片会包含一个私钥,当设备被卖出后,对应的私钥会被存储在第三方的契约账户中。一旦政府获得许可就会取出私钥查看传输的情报。然而,这种措施无疑刺激到了密码朋克们的神经,对于他们而言,这是政府企图建立“网络极权国家”的阴谋,必须反抗!结果白宫方面放弃了这一方案。这是属于密码朋克的胜利。

加密技术的发展有自己鲜明的特征。首先是制定标准的过程,几乎不存在混沌的状态。新的加密技术出现,旧的就随之淘汰,顺应自然;其次它也不存在拥塞状态,毕竟它分明就不是一种通讯技术,相比于电报、无线电、互联网以及区块链在初期遇到的拥塞问题,加密技术几乎不存在需要分割的稀缺资源,也就无需外部力量维持所有权的分配秩序;最后,纵观加密技术的历史,这项技术从诞生之初就很少申请过专利。当然现代加密技术倒是申请了不少专利,但是加密界有自己独特的一套共识——闭源的加密算法是不安全的。这或许不能构成对所有权混乱的理由,不过,由于政府最初对加密技术的封闭,所以它在发展过程中没有出现创造性的混乱状态,所以对于加密技术所有权的任何保护措施也就不太重要了。

微软托拉斯

微软帝国的崛起和侵权案件

1968年,比尔·盖茨接触到计算机的世界。他和保罗·艾伦(Paul Allen)一起为DEC(数字设备公司)编写程序。1975年7月,艾伦在Altair机器上成功地演示了改良过的BASIC语言五个月之后,微软(MicroSoft)公司成立了。这时候的微软靠卖BASIC语言的光盘获取版权收入,即便彼时的软件所有权概念还很模糊。

微软的将BASIC语言据为己有的做法在当时引起了轩然大波,而争论的中心发生在一家业余爱好者俱乐部——家酿计算机俱乐部最初的理念是“通过共享经验和相互交流构想,我们促进了这门艺术的发展,使更多的人用低价的计算机成为可能”。成员复制了微软的BASIC代码,并开始随意分发,在他们看来,微软的BASIC代码本来就属于公众。这种做法影响了微软的版权收入,同时也惹恼了比尔·盖茨。盖茨批判这帮人是强盗,但是这些人认为比尔·盖茨把几百人花了几年做出来的软件占为己用的行为才是强盗行径。

虽然盖茨在这次争论中缓和了态度,但是软件的版权问题迟迟没有定论。直到Altair公司被出售给加利福尼亚的一家较大的公司,这个公司声称BASIC语言归自己所有,并且禁止其他生产商使用。这回盖茨没有怂,微软同这家公司进行了长达6个月的诉讼大战,最终赢得了胜利。这个案件不仅仅宣告了微软对BASIC语言的所有权,也意味着一种定论:软件是私有财产。

1983-1986年,苹果公司和微软的达成合作,微软将Word等常用办公软件提供给了苹果,而苹果通过销售Macintosh电脑帮助微软销售软件。但是微软发布了Windows2.03,抢占了苹果公司在PC的市场份额,苹果公司这时冷静不下去了,以盗用Macintosh界面所有权的理由将微软告上法庭。虽然最终对于微软的所有控诉都被法院解除了,苹果公司也因此遭受了巨大的打击,但是微软也因为反托斯拉法案受到了美国联邦贸易委员会(FTC)的调查,并且在1994年签署一项包含了若干义务的法律协议,其中规定了微软不得在操作系统中捆绑销售自家的软件,同时微软也承诺不再使用任何许可协议。

浏览器战争中的反托斯拉

时间来到了1995年,这是互联网商业化的元年。这一年,如今已经是两大世界级电子商务巨头的eBay和Amazon上线运营,在中国,杭州的大学英语老师马云和宁波的电信员工丁磊离开公职,分别创办了阿里巴巴和网易。Jim clark和Mark Anderson成立的公司Netscape凭借Navigator(前身是Mosaic)这款网络浏览器迅速横扫互联网市场。在CERN(欧洲粒子研究院)研究员Tim Berners-Lee发明了HTML、HTTP协议和URL之后,信息通讯发生了变革,这让非技术用户上网成为了可能。这时候,Anderson等人就想着如何把图像和多媒体引入网络,最后的答案便是通过浏览器。

大公司总是习惯后知后觉,微软也不例外。在Netscape公司的Navigator浏览器迅速占领了超过90%的浏览器市场份额后,微软按捺不住了——以卖自家软件见长的微软很害怕Navigator成为网络的入口,用户便可以下载任何产商的软件。所以微软开始行动了,首先它成立了一个互联网平台和工具部门,其次通过威逼利诱的手段限制和控制Netscape公司,最后通过“行贿”的方式联合AOL推广自己的IE浏览器。当然,这些措施生效了,Netscape在浏览器市场上的份额迅速缩减了一半以上。然而,微软这种行为最终还得诉诸法律。

当商业利益上竞争不过巨头的时候,留给这些创业公司最后的武器就是反垄断法。在这场旷日持久的诉讼案件中,法院确定了微软的反垄断行为,但是并没有接受Jackson法官拆分微软的命令。2001年11月2日,联邦司法部与微软就案件达成了和解,但是这次和解既没有要求微软修改源码从Windows中剥离IE浏览器,也没有限制微软未来在Windows中捆绑其它软件,所以令很多人感到不满。

微软的故事告诉我们,当进入一个崭新的领域时,原先社会上比较分明的所有权和反托拉斯的界定问题变得模棱两可起来。大公司可以很快地在新兴的市场之上建立起法则这时候法律监管是滞后的。但是一旦新兴企业创造出自己的市场之后,原先的大公司还想要入场并部署自己的规则,那么不论是企业、用户还是政府都会介入进来反对垄断和维护秩序。

网络音乐

1999年,19岁的Shawn Fanning辍学创建了Napster这个革命性的网站,它允许用户在网站上自由地交换歌曲,这种免费的上传和分发的模式很快就颠覆了传统的唱片行业。传统的唱片行业掌控了音乐录制,分发渠道,前期宣传和明星包装,也同时形成了对音乐制作人的“奴役”和压榨。随着数字音乐技术(MP3)的成熟,有些歌手直接将自己的音乐上传到如Naspter这样的网站上,听众就可以自由地下载和分发了。

随着Napster网站上用户数的不断增加,盗版的问题也日益严重起来。原本歌手将音乐上传到网上,是想脱离唱片公司根深蒂固的体系,但是后果是歌手也没法获得相应的报酬了。于是,围绕网络空间中的知识产权保护也引来了大量的讨论。有人说“未来将会胜利,在网络世界中不存在所有权”,也有人说“知识产权的整个结构和价值都在改变……技术性障碍将几乎为零……法律本身变得无所适从或遭到削弱”。

但是从历史中走过来,我们发现网络世界的版权还是保留了下来。苹果公司的Jobs开创的iTunes和iPod从某种程度上,拯救了数字音乐的版权和唱片公司。早在2004年,iTunes store 在合法数字音乐市场的份额就超过了70%,2011年数字音乐的市场份额更是超过了实体音乐(刻录在唱片等载体上的音乐)。当正版商开始用资本发展网络商业模式时,它也会收购那些盗版的公司,自己也得到进一步发展。从这些方面看,不难得出数字版权更像是在唱片公司、数字音乐公司以及公众参与下共同制定的一项行业标准,然后由政府强制执行。所以最终胜利还是属于那些制定行业标准的家伙,尽管总有人存在一种理想国的幻想。

– 于 2018-05-01


[1] 技术简史
技术简史核心观点

解题思路

  1. 利用递归,将目录转换成 {:name: ".", :children: []} 结构
  2. 对于第一层目录名,前缀装饰成 T_branch = "├── " 或者 L_branch = "└── "
  3. 对于子目录,前缀装饰成 I_branch = "│ "或者SPACER = " "
    举例如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    .
    ├── tree.py # 不是最后一项,所以使用 T_branch 前缀
    ├── files.py
    ├── lists.py
    ├── tuples.py
    ├── resources
    │ └── README.md # 由于其父亲不是最后一项,所以使用 I_branch 前缀
    ├── recursion.py
    └── data # 是最后一项,所以使用 L_branch 前缀
    ├── output.txt # 由于其父亲是最后一项,所以使用 SPACE 前缀
    └── data.txt

python 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#!/usr/local/bin/python
# -*- coding: UTF-8 -*-

"""
list a directory in tree way.

children(path):
map(lambda name: tree(path, name), listdir(path))

tree(parent, dir_name):
if is_file(parent, dir_name):
return {'name': dir_name, 'children': []}
else:
children = children(join(parent, dir_name))
return {'name': dir_name, 'children': children}
"""
import os
import functools as fp

I_branch = "│ "
T_branch = "├── "
L_branch = "└── "
SPACER = " "

def _children(path):
return map(lambda filename: tree_format(path, filename), os.listdir(path))

def tree_format(parent, dir_name):
path = os.path.join(parent, dir_name)
is_file = os.path.isfile(path)
children = [] if is_file else _children(path)
return {'name': dir_name, 'children': list(children)}

def render_tree(tr):
name = tr['name']
children = tr['children']
return [name] + fp.reduce(lambda l, r: l + r,
map(lambda arg: render(len(children))(*arg),
enumerate(children)),
[])

def render(length):
def prefix(index, child):
is_last = (index == length - 1)
prefix_first = L_branch if is_last else T_branch
prefix_rest = SPACER if is_last else I_branch
tr = render_tree(child)
head = prefix_first + tr[0]
tail = [prefix_rest + t for t in tr[1:]]
return [head] + tail
return prefix

if __name__ == "__main__":
print '\n'.join(render_tree(tree('', sys.argv[1])))

$ python3 tree.py . #打印当前的目录的所有文件及子目录
.
├── tree.py
├── files.py
├── lists.py
├── tuples.py
├── resources
│ └── README.md
├── recursion.py
└── data
├── output.txt
└── data.txt

Clojure 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
(ns tree
(:require [clojure.java.io :as io]
[clojure.string :as str]))
(def L-branch "└── ")
(def T-branch "├── ")
(def I-branch "│ ")
(def SPACE " ")

(declare tree)

(defn children [path]
(map #(tree %) (.listFiles path)))

(defn tree [dir-name]
(let [path (io/file dir-name)
dir? (.isDirectory path)]
{:name (.getName path)
:children (if dir? (children path))}))

(defn render-tree [{name :name children :children}]
(cons name
(mapcat (fn [child index]
(let [last? (= index (dec (count children)))
prefix-first (if last? L-branch T-branch)
prefix-rest (if last? SPACE I-branch)
sub-tree (render-tree child)]
(cons (str prefix-first (first sub-tree))
(map #(str prefix-rest %) (rest sub-tree)))))
children
(range))))

(defn -main [& args]
(->>
(tree (first args))
(render-tree)
(str/join "\n")
(println)))
$ lein run -m tree .
.
├── tree.py
├── files.py
├── lists.py
├── tuples.py
├── resources
│ └── README.md
├── recursion.py
└── data
├── output.txt
└── data.txt

Golang 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
package main

import (
"fmt"
"io/ioutil"
"os"
"path"
"strings"
)

const (
I_branch = "│ "
T_branch = "├── "
L_branch = "└── "
SPACER = " "
)

type entry struct {
name string
children []entry
}

func (e entry) String() string {
if len(e.children) == 0 {
return e.name
} else {
s := e.name
for _, child := range e.children {
s += child.String()
}

return s
}
}

func Children(path string) []entry {
result := []entry{}
files, _ := ioutil.ReadDir(path)
for _, f := range files {
result = append(result, Tree(path, f.Name()))
}

return result
}

func Tree(parent, dirName string) entry {
realPath := path.Join(parent, dirName)
theChildren := []entry{}
if f, ok := os.Stat(realPath); ok == nil {
if f.IsDir() {
theChildren = Children(realPath)
}
}
return entry{name: dirName, children: theChildren}
}

func RenderTree(e entry) []string {
name := e.name
children := e.children
result := []string{name}

for index, child := range children {
subTree := RenderTree(child)
prefixFirst := T_branch
prefixRest := I_branch
if index == len(children)-1 {
prefixFirst = L_branch
prefixRest = SPACER
}

result = append(result, prefixFirst+subTree[0])

for _, sub := range subTree[1:] {
result = append(result, prefixRest+sub)
}
}
return result
}

func main() {
fmt.Println(strings.Join(RenderTree(Tree("", os.Args[1])), "\n"))
}
$ go run tree.go .
.
├── data
│ ├── data.txt
│ └── output.txt
├── files.py
├── lists.py
├── recursion.py
├── resources
│ └── README.md
├── tree.py
└── tuples.py

NodeJS 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const path = require('path')
const fs = require('fs')
const I_branch = '│ '
const T_branch = '├── '
const L_branch = '└── '
const SPACER = ' '

function children(path) {
return fs.readdirSync(path).map(filename => tree(path, filename))
}

function tree(parentDir, dirName) {
let realPath = path.join(parentDir, dirName)
let isDir = fs.statSync(realPath).isDirectory()
return {name: dirName, children: isDir ? children(realPath) : []}
}

function prefix(len) {
return (tr, index) => {
let isLast = len == index + 1
let prefixFirst = isLast ? L_branch : T_branch
let prefixRest = isLast ? SPACER : I_branch
let [head, ...tail]= renderTree(tr)

return [prefixFirst + head].concat(tail.map(name => prefixRest + name))
}
}

function renderTree({name: name, children: children}) {
return [name]
.concat(children
.map(prefix(children.length))
.reduce((l, r) => l.concat(r), []))
}

console.log(renderTree(tree('', process.argv[2])).join('\n'))

$ node tree.js .
.
├── data
│ ├── data.txt
│ └── output.txt
├── files.py
├── lists.py
├── recursion.py
├── resources
│ └── README.md
├── tree.py
└── tuples.py

Kotlin script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import java.io.File

val I_branch = "│ "
val T_branch = "├── "
val L_branch = "└── "
val SPACER = " "

data class Entry (val name: String, val children: List<Entry>)

fun children(path: File): List<Entry> {
return path.listFiles().map {tree(it)}
}

fun tree(path: File): Entry {
val isDir = path.isDirectory()
return Entry(path.getName(), if(isDir) children(path) else listOf<Entry>())
}

fun renderTree(tree: Entry): List<String> {
val name = tree.name
val children = tree.children

return listOf(name) + children.mapIndexed { i, e -> prefix(children.size)(i, e) }.fold(listOf<String>()) {l, r -> l + r}
}

fun prefix(size: Int): (Int, Entry) -> List<String> {
return {index, entry ->
val isLast = index + 1 == size
val prefixFirst = if(isLast) L_branch else T_branch
val prefixRest = if(isLast) SPACER else I_branch
val subTree = renderTree(entry)

listOf(prefixFirst + subTree.first()) + subTree.drop(1).map {t -> prefixRest + t}
}
}

println(renderTree(tree(File(args[0]))).joinToString("\n"))

$ kotlinc -script tree.kts .
.
├── tree.py
├── files.py
├── lists.py
├── tuples.py
├── resources
│ └── README.md
├── recursion.py
└── data
├── output.txt
└── data.txt

Scala

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import java.io._
val I_branch = "│ "
val T_branch = "├── "
val L_branch = "└── "
val SPACER = " "

case class Entry(name: String, children: List[Entry])

def children(path: File): List[Entry] = path.listFiles().toList.map((it: File) => tree(it))

def tree(path: File): Entry = Entry(path.getName(), if(path.isDirectory()) children(path) else List[Entry]())

def prefix(size: Int) = (index: Int, entry: Entry) => {
val isLast = index + 1 == size
val prefixFirst = if(isLast) L_branch else T_branch
val prefixRest = if(isLast) SPACER else I_branch
val subTree = renderTree(entry)
List(prefixFirst + subTree.head) ++ subTree.tail.map(t => prefixRest + t)
}

def renderTree(tree: Entry): List[String] = {
val name = tree.name
val children = tree.children

return List(name) ++ children
.zipWithIndex
.map({case (e: Entry, i: Int) => prefix(children.size)(i, e)})
.fold(List[String]())((l, r) => l ++ r)
}

println(renderTree(tree(new File(args(0)))).mkString("\n"))

$ scala tree.scala .
.
├── tree.py
├── files.py
├── lists.py
├── tuples.py
├── resources
│ └── README.md
├── recursion.py
└── data
├── output.txt
└── data.txt

Elixir

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#!/usr/bin/env elixir

defmodule Tree do
def main([dir | _]) do
dir |> tree_format |> render_tree |> Enum.join("\n") |> IO.puts
end

defp children(path) do
if (path |> File.dir?) do
File.ls!(path) |> Enum.map(fn f -> tree_format(path, f) end)
else
[]
end
end

defp tree_format(parent_dir \\ ".", dir_name) do
%{:name => dir_name, :children => Path.join(parent_dir, dir_name) |> children}
end

defp decorate(is_last?, [parent | children]) do
prefix_first = (if (is_last?), do: "└── ", else: "├── ")
prefix_rest = (if (is_last?), do: " ", else: "│ ")
[prefix_first <> parent | children |> Enum.map(fn child -> prefix_rest <> child end)]
end

defp render_tree(%{name: dir_name, children: children}) do
[dir_name
| children
|> Enum.with_index(1)
|> Enum.map(fn {child, index} -> decorate(length(children) == index, render_tree(child)) end)
|> List.flatten]
end

end

Tree.main(System.argv)

$ elixir tree.exs .
.
├── tree.py
├── files.py
├── lists.py
├── tuples.py
├── resources
│ └── README.md
├── recursion.py
└── data
├── output.txt
└── data.txt

函数式编程是什么

函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。 – wiki

例子一 累加运算

1
2
3
4
5
6
7
8
9
10
11
12
13
// sum
List<Integer> nums = Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 10);

public static Integer sum(List<Integer> nums) {
int result = 0;
for (Integer num : nums) {
result += num;
}

return result;
}

sum(nums); // -> 46

同样的代码用 Java8 Stream 实现

1
Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 10).stream().reduce(0, Integer::sum);

同样的代码用 Clojure 实现

1
2
(apply + [0 1 2 3 4 5 6 7 8 10]) ; -> 46
#_(reduce + [0 1 2 3 4 5 6 7 8 10])

例子二 fabonacci数列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// java
public static int fibonacci(int number) {
if (number == 1) {
return 1;
}
if(number == 2) {
return 2;
}

int a = 1;
int b = 2;
for(int cnt = 3; cnt <= number; cnt++) {
int c = a + b;
a = b;
b = c;
}
return b;
}
1
2
3
4
5
6
// java8
Stream.iterate(new int[]{1, 1}, s -> new int[]{s[1], s[0] + s[1]})
.limit(10)
.map(n -> n[1])
.collect(toList())
// -> [1, 2, 3, 5, 8, 13, 21, 34, 55, 89]
1
2
3
4
5
// clojure
(->> (iterate (fn [[a b]] [b (+ a b)]) [1 1])
(map second)
(take 10))
; -> (1 2 3 5 8 13 21 34 55 89)

比起命令式的语言,函数式语言更加关注执行的结果,而非执行的过程。

函数式编程的历史

从Hilbert 23个数学难题谈起

1900年,Hilbert 提出了数学界悬而未决的10大问题,后续陆续添加成了23个问题,被称为著名的 Hilbert 23 Problem。针对其中第2个决定数学基础的问题——算术公理之相容性,年轻的哥德尔提出了哥德尔不完备定理,解决了这个问题形式化之后的前两点,即数学是完备的吗?数学是相容的吗?哥德尔用两条定理给出了否定的回答。所谓不完备,即系统中存在一个为真,但是无法在系统中推导出来的命题。比如:U说:“U在PM中不可证”。虽然和说谎者很类似,但其实有明显的差异。我们可以假设U为可证,那么可以推出PM是矛盾(不相容)的;但是假设U不可证,却推导不出PM是矛盾的。U的含义是在M中不可证,而事实上,它被证明不可证,所以U是PM中不可证的真命题。基于第一条不完备定理,又可以推导出第二条定理。如果一个(强度足以证明基本算术公理的)公理系统可以用来证明它自身的相容性,那么它是不相容的。

而最后一个问题,数学是确定的吗?也就是说,存在一个算法判定一个给定的命题是否是不确定的吗(Entscheidungsproblem 确定性问题)?这个问题引起了阿隆佐·邱奇和年轻的阿兰·图灵的兴趣。阿隆佐·邱奇的lambda calculus和图灵的图灵机构造出了可计算数,图灵的那篇论文 ON COMPUTABLE NUMBERS, WITH AN APPLICATION TO THE ENTSCHEIDUNGSPROBLEM 的意义不在于证明可计算数是否可数,而在于证明可判定性是否成立。在1936年他们对判定性问题分别独立给出了否定的答案。也就是现在被我们熟知的图灵停机问题:不存在这样一个程序(算法),它能够计算任何程序(算法)在给定输入上是否会结束(停机)。图灵借此发明了通用图灵机的概念,为后来的冯·诺依曼体系的计算机体系提供了理论基础。

Lambda Calculus

Lambda Calculus

Lambda 表达式包含三个要素

  1. 变量
  2. lambda 抽象
  3. lambda 应用
    据此我们可以用函数给出布尔值的定义
    1
    2
    3
    4
    5
    6
    7
    8
    data BOOL = FALSE | TRUE
    TRUE = λx.λy.x
    FALSE = λx.λy.y

    not = λb.b FALSE TRUE
    and = λb1.λb2.b1 b2 FALSE
    or = λb1.λb2.b1 TRUE b2
    xor = λb1.λb2.b1 (not b2) b2
    自然数的定义
    1
    2
    3
    4
    5
    6
    7
    8
    data NAT = Z | S NAT
    0 = λf.λs.s
    1 = λf.λs.f s
    2 = λf.λs.f f s

    succ n = λf.λs.f (n f s)
    zero? n = n (λb.FALSE) TRUE
    add = succ n1 n2

函数式编程语言的发展

在这之后,随着通用计算机的产生,人们发觉使用机器码写程序太没有效率。所以1956年左右,John Buckus发明了Fortran(FORmula TRANslating 的缩写)语言,如果对编译原理有了解,那么对BNF范式就不陌生了。与此同时,John McCarthy 发明了Lisp语言,现代的Clojure就是Lisp的方言之一。1966年,Niklaus Wirth发明了Pascal。1969年,Ken Thompson和Dennis Ritchie发明了C语言,过程式语言由于其高效和可移植性迅速崛起。1973年,Robin Milner 发明了ML(Meta Language),后来演变成了OCaml和Stardard ML。1977年,John Buckus在其图灵奖的演讲中创造了 Functional Programming 这个词。1990年,惰性求值的函数式编程语言 Haskell 1.0 发布。

编程语言发展历史

神奇的 Y Combinator

1
2
3
4
5
(def Y (fn [f]
((fn [x] (x x))
(fn [x]
(f (fn [y]
((x x) y)))))))

Lisp、ML以及Haskell的关系

Lisp是动态语言,使用S表达式
ML和Haskell都是静态强类型函数式语言
ML是第一个使用Hindley-Milner type inference algorithm的语言
Lisp和ML都是call-by-value,但是Haskell则是call-by-name
Lisp和ML都是不纯的编程语言,但是Haskell是side effect free的

函数是一等公民

函数是一等公民,指的是你可以将函数作为参数、返回值、数据结构存在,而且不仅可以用函数名引用,甚至可以匿名调用。

1. 作为参数

1
(map inc [1 2 3 4 5]) ;-> (2 3 4 5 6) ;; inc is an argument

2. 作为返回值

1
2
3
4
5
6
7
8
(defn add [num] 
(fn [other-num] (+ num other-num))) ;; as return-value
(def add-one (add 1))
(add-one 2) ;-> 3

(defn flip [f] ;; as argument and return-value
(fn [x y]
(f y x)))

3. 数据结构

1
2
3
(def dictionary {:a "abandon"}) ;; map is also a function, data is code.
(dictionary :a) ;-> "abandon"
(:a dictionary) ;-> "abandon"

4. 匿名函数

1
2
3
4
5
6
((fn [x] (* x x))
2) ;-> 4

(map
(fn [num] (+ 1 num)) ;; anonymous function
[1 2 3 4 5]) ;-> (2 3 4 5 6)

5. 模块化

在面向对象中,对象是一等公民。所以我们处处要从对象的角度去考虑计算问题,然后产生一种共识——数据应该和它相关的操作放到一起,也就是我们所说的封装。确实没错,但是我们得知道封装的意义在哪里?功能内聚好理解(分块)和局部性影响(控制可变性)。函数式编程同样考虑这些,功能内聚不一定要用类的方式(考虑一下JS的prototype,也是一种面向对象),只要模块做得好,一样能达到效果。局部性影响,其本质是封装可变因素以避免其扩散到代码各处。函数式给出了自己的答案,消除可变因素。

高阶函数和惰性求值也非常有利于模块化。

纯函数和不可变性

纯函数是指执行过程中没有副作用的函数,所谓副作用是说超出函数控制的操作,比如在执行过程中操作文件系统、数据库等外部资源。纯函数还具有引用透明性的特点,也就是同样的输入导致同样的输出,以至于完全可以用函数的值代替对函数的调用。

引用透明

举个例子:

1
2
3
4
(inc 1) ; -> 2

(= (inc (inc 1)
(inc 2))) ; -> true

你们可能就会问,这种东西究竟有什么用呢?纯函数可以很方便地进行缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(defn fibonacci [number]
(if (or (zero? number) (= 1 number)) 1
(+
(fibonacci (dec number))
(fibonacci (- number 2)))))
(fibonacci 30) ; -> "Elapsed time: 185.690208 msecs"

(def fibonacci
(memoize (fn [number] ;;
(if (or (zero? number) (= 1 number)) 1
(+
(fibonacci (dec number))
(fibonacci (- number 2)))))))
(fibonacci 30) ; -> "Elapsed time: 0.437114 msecs"

不可变计算

谈到不可变性,我们做个游戏。统计在座的一共有多少人数。我们都知道从某个人开始依次报数,最后得到的数字就是总人数,其实这就是一种不可变计算的游戏,为什么这么说呢?因为报数其实一个计算的过程,第一个人计算出1这个数,传递给第二个人。然后第二个人拿着前面的1进行加一操作,然后把结果2传递给后面的人做加法,以此类推。为了提高统计的效率,我也可以进行分组,然后每组自行报数,最后统计结果。但是如果我在白板上写个数字1,然后让大家来过来该这个数字,很大可能会出现错误,因为这个数字成为了竞态条件。在多并发的情况下,就得用读写锁来控制。所以不可变性特别利于并发。
不可变性

不可变的链式结构

好了,现在我们有个新的需求,设计一个不可变列表收集大家的名字。每个节点存储一个姓名的字符串,并且有个指针指向下一个节点。但是这也打破了列表的不可变性。怎么办?我们可以把新的节点指向旧有的列表,然后返回一个新的列表。这就是不可变列表实现的机制。随便一提,这也是区块链不可变特征的由来。

不可变的链式结构

Clojure的创造者Rich Hickey扩展了Ideal Hash Tree数据结构,实现了Persistent Vector。由于此处的叶子节点可以扩展成32个,所以可以大量存储数据。利用Ideal Hash Tree的特点可以快速索引出数据,与此同时,数据的“增删改”也能做到近常数化的时间,并且总是产生新的数据结构替换原有的数据结构,即一种不可变的链式存储结构。
Clojure Persistent Vector

不可变的树状结构

Zipper数据结构类似于文本编辑器中的 gap buffer,编辑文本时,光标左边和右边分别是独立的buffer,光标处也是单独的buffer,这样便可以方便地添加文字,也很方便删除左右buffer中的文字;移动光标会涉及buffer之间的拷贝。基本上能在常数时间内完成编辑。Zipper数据结构模仿了这种方式,能在常数时间内完成树的编辑工作,也能很快地重新构建一棵树。
不可变的树状结构

递归

可计算很大问题就是得实现递归功能。

1
2
3
4
(defn reverse-seq [coll]
(when-let [elem (first coll)]
(concat (reverse-seq (rest coll)) [elem])))
(reverse-seq [1 2 3]) ; -> (3 2 1)

和循环无异的尾递归

1
2
3
4
5
(defn gcd [& nums]
(reduce #(if (zero? %2)
%
(recur %2 (mod % %2))) nums))
(gcd 8 16) ; -> 8

生成式测试

生成式测试会基于输入假设输出,并且生成许多可能的数据验证假设的正确性。

1
2
3
4
5
6
7
8
9
10
11
(defn add [a b]
(+ a b))
;; 任取两个整数,把a和b加起来的结果减去a总会得到b。
(def test-add
(prop/for-all [a (gen/int)
b (gen/int)]
(= (- (add a b) a) b)))


(tc/quick-check 100 test-add)
; -> {:result true, :num-tests 100, :seed 1515935038284}

测试结果表明,刚才运行了100组测试,并且都通过了。理论上,程序可以生成无数的测试数据来验证add方法的正确性。即便不能穷尽,我们也获得一组统计上的数字,而不仅仅是几个纯手工挑选的用例。

抽象是什么

抽取共性,封装细节,忘记不重要的差异点。这样的好处是可以做到局部化影响和延迟决策。
抽象屏障

命名

命名就是一种抽象,重构中最重要的技法就是重命名和提取小函数

1
2
3
4
5
6
(* 3 3 3)
(* x x x)
(* y y y)
->
(defn cube [x]
(* x x x))

延迟决策

例如:我们定义数对 pair

1
2
3
pair:: (cons x y)
first pair -> x
second pair -> y

那么它的具体实现会是这样的

1
2
3
4
5
6
7
8
(defn cons [x y]
(fn [m]
(cond (= m 0) x
(= m 1) y)))
(defn first [z]
(z 0))
(defn second [z]
(z 1))

也可以是这样的,还可以是其它各种各样的形式。

1
2
3
4
5
6
7
(defn cons [x y]
(fn [b]
(b x y))
(defn first [z]
(z (fn [x y] x)))
(defn second [z]
(z (fn [x y] y)))

高阶函数

高阶函数就是可以接收函数的函数,高阶函数提供了足够的抽象,屏蔽了很多底层的实现细节。比如Clojure中的map高阶函数,它接收(fn [v] ...),把一组数据映射成另外一组数据。

过程抽象

1
(map inc [1 2 3 4 5]) ; -> (2 3 4 5 6)

这些函数抽象出映射这样语义,除了容易记忆,还能很方便地重新编写成高效的底层实现。也就是说,一旦出现了更高效的map实现算法,现有的代码都能立刻从中受益。

函数的组合

函数组合之后会产生巨大的能量

神奇的加法

1
(((comp (map inc) (filter odd?)) +) 1 2) ; -> 4

怎么去理解这个函数的组合?我们给它取个好听的名字

1
2
3
4
5
6
7
(def special+ ((comp (map inc) (filter odd?)) +))
(special+ 1 2) ; -> 4

; <=> 等价于
(if (odd? (inc 2))
(+ 1 3))
1)

这个未必是个好的组合方式,但是不可否认的是,我们可以用这些随意地将这些函数组合到一起,得到我们想要的结果。

transducer

1
2
3
(def xf (comp (filter odd?) (take 10)))
(transduce xf conj (range))
;; [1 3 5 7 9 11 13 15 17 19]

这里直接将求值延迟到了transduce计算的时候,换句话说,xf定义了一种过程:filter出奇数并取出前10个元素。同等的代码,如果用表达式直接书写的话,如下:

1
2
3
(->> (range)
(filter odd?)
(take 10))

这里的问题就是我们没能使用高阶函数抽象出过程,如果把 conj 换成其他的reduce运算,现在的过程无法支撑,但是tranducers可以!

1
(transduce xf + (range)) ;-> 100

我们再看一个tranducer的神奇使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
(defn log [& [idx]]
(fn [rf]
(fn
([] (rf))
([result] (rf result))
([result el]
(let [n-step (if idx (str "Step: " idx ". ") "")]
(println (format "%sResult: %s, Item: %s" n-step result el)))
(rf result el)))))

(def ^:dynamic *dbg?* false)

(defn comp* [& xforms]
(apply comp
(if *dbg?*
(->> (range)
(map log)
(interleave xforms))
xforms)))

(binding [*dbg?* true]
(transduce
(comp*
(map inc)
(filter odd?))
+
(range 5))) ;; -> 9

Step: 0. Result: 0, Item: 1
Step: 1. Result: 0, Item: 1
Step: 0. Result: 1, Item: 2
Step: 0. Result: 1, Item: 3
Step: 1. Result: 1, Item: 3
Step: 0. Result: 4, Item: 4
Step: 0. Result: 4, Item: 5
Step: 1. Result: 4, Item: 5

之所以会出现上述的结果,是因为interleave xforms(map inc)以及(filter odd?)和logs进行了交叉,得到的结果是(comp (map inc) (log) (filter odd?) (log)),所以如果是偶数就会被filter清除,看不见log了。

首先一定得理解:每个tranducer函数都是同构的!
形式如下

1
2
3
4
(defn m [f]
(fn [rf]
(fn [result elem]
(rf result (f elem)))))

这意味着(m f)的函数都是可以组合的,组合的形式如下:

1
(comp (m f) (m1 f1) ...)

展开之后

1
2
3
4
5
6
7
((m f) 
((m1 f1)
((m2 f2) ...)))
->
(fn [result elem]
(((m1 f1)
((m2 f2) ...)) result (f elem)))

所以可以看到第一个执行的一定是 comp 的首个 reducing function 参数。故:

  1. xform 作为组合的前提
  2. 执行顺序从左到右;
  3. + 作为 reducing function 最后执行;

Monad

什么是Monad呢?A monad is just a monoid in the category of endofunctors.

  1. Identity—For a monad m, m flatMap unit => m
  2. Unit—For a monad m, unit(v) flatMap f => f(v)
  3. Associativity—For a monad m, m flatMap g flatMap h => m flatMap {x => g(x) flatMap h}
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    // java8 实现的 9*9 乘法表
    public class ListMonad<T> {
    private List<T> elements;

    private ListMonad(T elem) {
    this.elements = singletonList(elem);
    }

    private ListMonad(List<T> elems) {
    this.elements = elems;
    }

    public <U> ListMonad<U> flatmap(Function<T, ListMonad<U>> fn) {
    List<U> newElements = new ArrayList<>();
    this.elements.forEach(elem -> newElements.addAll(fn.apply(elem).elements));
    return new ListMonad<>(newElements);
    }

    public <X> ListMonad<X> uint(X elem) {
    return new ListMonad<>(elem);
    }

    public <U> ListMonad<U> apply(ListMonad<Function<T, U>> m) {
    return m.flatmap(this::map);
    }

    public <U> ListMonad<U> map(Function<T, U> fn) {
    return flatmap(t -> uint(fn.apply(t)));
    }

    public static void main(String[] args) {
    ListMonad<Integer> m = new ListMonad<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
    ListMonad<Integer> m1 = new ListMonad<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));

    ListMonad<Integer> list = m.apply(m1.map(x -> y -> x * y));
    // [1...81]
    }
    }

表达式优于语句

S表达式

  1. 原子,或者;
  2. 形式为 (x • y) 的表达式,其中x和y也是S表达式。

举个例子,递增一组数据,过滤奇数,然后进行排序,最终取出第一个。如果取不到,返回:not-found

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(-> [1 2 3] 
(->> (map inc)
(filter odd?)
(sort)
(first))
(or :not-found))
; -> 3
(-> [1 1 3]
(->> (map inc)
(filter odd?)
(sort)
(first))
(or :not-found)
; -> :not-found

当然你也可以写成

1
2
3
4
(if-let [r (first (sort (filter odd? (map inc [1 1 1]))))] 
r
:not-found)
; -> :not-found

其实两者都是S表达式,但是下面的写法更加偏向于语句。从串联起来读来讲,前者明显是由于后者的。这要是放在其他函数式语言上,效果更加显著。比如下面重构if-else控制语句到Optional类型。

if-else -> Optional

1
2
3
4
5
6
7
8
9
10
11
12
Optional<Rule> rule = ruleOf(id);
if(rule.isPresent()) {
return transform(rule.get());
} else {
throw new RuntimeException();
}

public Rule transform(Rule rule) {
return Rule.builder()
.withName("No." + rule.getId())
.build();
}

这是典型的语句可以重构到表达式的场景,关键是怎么重构呢?
第一步,调转if

1
2
3
4
5
6
7
Optional rule = ruleOf(id);

if(!rule.isPresent()) {
throw new RuntimeException();
}

return transform(rule.get());

第二步,Optional.map函数

1
2
...
return rule.map(r -> transform(r)).get();

第三步,inline transform函数

1
2
3
4
...
rule.map(r -> Rule.builder()
.withName("No." + r.getId())
.build()).get();

第四步,Optional.orElseThrow函数

1
2
3
4
5
...
rule.map(r -> Rule.builder()
.withName("No." + r.getId())
.build())
.orElseThrow(() -> new RuntimeException());

第五步,注if释语句中的throw new RuntimeException()

1
2
3
if(!rule.isPresent()) {
// throw new RuntimeException();
}

这时候发现语句中为空,即可将整个语句删除。可以考虑inline rule

1
2
3
4
ruleOf(id).map(r -> Rule.builder()
.withName("No." + r.getId())
.build())
.orElseThrow(() -> new RuntimeException());

完毕。

我们认识事物的方式

  1. 把几个简单的想法合并成一个复合概念,从而创造出所有复杂的概念。
  2. 简单的或复杂的两种思想融合在一起,并立即把它们联系起来,不要把它们统一起来,从而得到它所有的关系思想。
  3. 把他们与其他所有陪伴他们的真实存在的想法分开:这就是所谓的抽象,因此所有的一般想法都是被提出来的。

推荐的书籍

  1. 逻辑的引擎
  2. 函数式编程思维
  3. 算机程序的构造和解释
    推荐的书籍

参考资料

  1. 图灵停机问题
  2. 康托尔、哥德尔、图灵 - 永恒的金色对角线
  3. Y combinator in Clojure
  4. 希尔伯特的23个问题
  5. 再谈哥德尔不完备定理
  6. wiki 函数式编程
  7. lambda 演算
  8. History of functional programming
  9. 函数式编程的早期历史
  10. 走进计算机文化史
  11. SICP
  12. Lambda Calculus and the Decision Problem

人类互相交流的欲望从未减弱过。

作者和书

这本书是汤姆·斯坦迪奇(Tom Standage)于1997年完成,1998年9月第一次出版。中文版是后浪|江西出版社于2017-8年出版。作者毕业于牛津大学,主修工程学和电脑科技,在《经济学人》杂志科技版担任主编。出版过的图书作品有《从莎草纸到互联网》、《六个瓶子里的世界史》等,本书是其代表作和畅销之作,已被拍成纪录片。

电报简史

脑图见文末。本书的概述请见简友半目李《当时的明月换拨人看》

我们沿着时间之线溯回到故事源头。1746年法国著名科学家、修道院院长诺莱做了一个电流传导的实验,成功证明了电流可以完成长距离即时传播。可是在那之后漫长的一百年,电流传播信息却并未得到发展,反而是查普发明了感观电报(一种通过望远镜观察电报塔机械臂获取信息的技术)和“电报”(telegraphe 远方的书写者)这个词。1793年法国建成第一座电报塔。次年,法国国家电报系统的第一条支线巴黎-里尔落成,法国的观感电报网由此形成。

时势造英雄。1832年,从欧洲回美国的萨利号船上,塞缪尔·F.B.摩尔斯听到同船的科学家提到了电流可以即时传播,灵感之下,想到了电报的无限可能,短短数日,就创造出摩尔斯电码,而这,几乎是整个电报行业的基石。四年之后,1836年,大洋彼岸的威廉姆·福塞吉尔·库克同样意识到电报的巨大前景。随后的两年,两个人在电学科学家的帮助下,都独立解决了流量远距离传导的难题。而摩尔斯更进一步改进了摩尔斯电码,让它可以直接编码字母。技术难题攻克后,1837年,库克和惠斯通合作建立第一条依附于铁路公司的电报线;1844年,摩尔斯说服政府建立了一条华盛顿-巴尔的电报线。1845年,几乎同时,摩尔斯和库克各自建立了磁力电报公司和电力电报公司。自此欧洲各国和北美的电报网日益发达起来。

各国自家的电报网四通八达之后,国与国之间沟通的诉求日益迫切。1849年3月份,普鲁士和奥地利签订了第一个国际电报互联协议,虽然基于类似香港海关的别扭形式,但是维也纳和柏林这两座城市终于可以互通电报了。随着水下铺设电报线技术的成熟,隔着英吉利海峡相望的英国和法国于1852年完成第一封从伦敦到巴黎的电报通信。而随之富贵的是那些生产古塔胶的公司,因为水下铺设的电线需要古塔胶的保护。1858年8月5日,历史性的时刻来临了。长约3280公里的大西洋海底电缆铺设完成,这意味着欧洲和北美洲的电报网络第一次连接成功。尽管由于技术问题,这条线路服役不到一个月就完全瘫痪。1866年,经过充分科学的试验和前车之鉴的经验积累,跨大西洋电报网络再次连接。这次,它真的可以在两个大洲之间传递各色信息,包括商业贸易,军事情报还有新闻等。

不过很快,电报在超载的信息面前开始显露出疲态。由于需要发送的电报太多,发报员根本忙不过来,导致大量电报堆积,以至于人们惊讶地发现在同城使用电报,通信的速度竟然不如信差。为了节省电报的带宽,英国的发明家克拉克发明了基于蒸汽的气动传送管,利用蒸汽的推力将装有电报卡的小盒子快速地发射到目的电报中心。这看似滑稽的设计,却实实在在地解决了电报带宽不足的问题。19世纪70年代,全世界都在兴建电报网络。维多利亚时代(1837-1901)的互联网在这段时间里初具规模,它混杂着各国的电报网络,海底光缆,气动管和跑腿信差。

英雄时代也会落幕的。1871年10月,美国举办了摩尔斯电报大游行,庆祝这位80岁高龄的老人为全人类通信所做的贡献。我们的电报之父——摩尔斯庄严地用摩尔斯电码敲下了自己的名字,正式告别电报界。次年逝世。

大师的陨落,宛若宣告一个时代的终结。电报以及整个电报行业开始退出历史的舞台。1872年,波士顿的约瑟夫·B.斯特恩斯发明了双工器,可以用一条电报线完成收和发的动作。1874年,博多机将一条线路的容量扩展到原来的12倍。同年,爱迪生发明了四工电路。1876年,亚历山大·格雷厄姆·贝尔通过对谐波电报的研究,发明了电话机并申请了专利。19世纪80年代,电学热持续升温,电报行业以肉眼可见的速度衰退。1903年,英国发明家唐纳德·莫里发明了电传打字机,这几乎宣判了电报员的“死刑”。自此之后,电报就消失在了人们的视野里。2006年,美国的西部联合公司宣布停止办理一切电报业务,以后恐怕只能在博物馆里才能追忆到那段辉煌的历史。

电报的思考

到底是技术带来了社会范式的改变,还是人类的诉求本身?

开篇提到过人类总是渴望互相交流,查普发明感观电报绝非偶然,本质上是察觉到人想和远距离的其他人即时交流的诉求。所以是诉求本身带动了技术创新。当技术创新成功发展之后,首先是富人阶层最先享受到成果,资本的罪恶本质是压榨了普罗大众的劳动时间得来了剩余价值。这些剩余价值让富人免去工作,把自己的时间消耗在这些技术创新激发的原始诉求上。然后,商人出于无利不起早的心态,会极力追求更低成本的技术创新,在市场竞争下,用低廉的价格吸引穷人进入资本家新一轮的资本累积当中。商业从根本上就是趋利避害的。

当技术的创新在一些方面节约了人们的时间,人们必然会将这部分时间浪费在其它方面或者干脆全部沉浸式地浪费原来那部分事情上,比如:网上聊天。我们之所以会有社会范式改变的感觉,就是由于我们自己原来的生活节奏被打乱了。以前是不得已而为之,现在是得已而不为之或者为之,突然一下拥有了选择的自由,算是很合情合理的设定。用系统性思考模式,人类及其活动是个大系统,本身就具有自组织的特征,所谓自组织就是让自己变得复杂的能力。远距离的交流变得快捷,意味着系统内部的信息连接更多更紧密,原因是速度变快了,同样的时间内可以和更多人建立关系,同时交流也会更加频繁。这样的带来的影响是什么呢?那就是人类群体变得更加复杂,外在行为更加诡异,也越发难以被消灭。

回到问题本身,这个问题就不应该用线性思考方式思考。人类的诉求激发了技术创新,技术创新又改变了人类的生活,激发了更进一步的诉求。说到底就是形成了一种增强回路,社会范式不过是增强回路自然而然表现出的群体的一致的外在行为模式。

电报网络和20世纪末的互联网有什么关系?

抛开技术本身不谈,电报网络和互联网都承载了同样的目标——让人类交流更加便利,本质上没有区别。常说电子邮件是互联网上的杀手级应用,我看不见得,170年前的电报早就具备了这样的能力,甚至“邮箱地址”这种也早就出现了。我觉的互联网真正的厉害之处,在于极大地丰富了信息的表现方式,利用超媒体链接技术,实现了多种类型信息(文本,照片和视频)的互联,还有基于这些信息上层抽象——服务(保险,云服务等等)。再加上计算机和移动设备这类载体的普及,用户群暴增。时下,如果不用互联网可能会被归为异类。

下一个爆发的是什么网呢?

我们分析一下互联网是个什么东西。互联网的要素有虚拟的人,虚拟的团体或公司,信息,网络,数字货币等。互联网上除了通讯和智库这些基本的要素外,还有由此构建的各种商业服务,如:阿里巴巴的电子商务帝国。当然还有游戏这种天然电子消费品。信息交互的速度足够快了,信息的载体也非常丰富,设备也在不断革新贴近人类。那么还有什么可以改善的呢?从历史发展的角度看问题,真正革新的技术从来都不是拿着颠覆什么商业模式的口号打出名声的,它的出发点一定是帮助解决人类的原始诉求。

思考中…

答案会是区块链吗?

于2018年2月4日

相关资料

维多利亚时代的互联网


[1]从莎草纸到互联网:社交媒体2000年
[2]六个瓶子里的世界史

维多利亚时代的互联网.png

世界是普遍联系、永恒发展的,这是我很欣赏的一名大学马原老师奉为圭臬的话,也是给我很大触动的金玉良言。世界是一个大系统,其中有纷繁复杂的事物,用独特的行为方式互相影响,或直接或间接,要么直截了当因果相连,要么兜兜转转蝴蝶效应。如果持不可知论,世界将永存混沌。系统总是比看上去复杂,但是其中玄妙又遵循因果。依照系统思考的基本原则,系统的行为总是由系统的结构决定。我们不愿意看到的很多现象,归根结底都是系统性问题,是系统的内部结构决定的行为特征。这是一个很重要的问题。我们只有正视并承认这一点才有重塑系统的勇气和可能。

系统结构是不胜枚举的,但是我们总可以抽象出模式(系统基模 archetypes)提取特征。不论是还原论还是整体论,只要能帮助我们分析问题都是好的理论。在分析过程中,使用还原论分解系统的元素,然后把这些元素放回原位,互相关联起来,组合勾画出系统的反馈回路。从整体论的视角重塑系统,思考反馈回路又会产生怎样的行为。系统思考的研究者将特定的、会引发特定行为的系统结构成为系统基模,常见的8种系统基模陷阱分别是政策阻力、公地悲剧、目标侵蚀、竞争升级、富者愈富、转嫁负担、规避规则和目标错位

可我们不禁想问:系统思考究竟是什么?要回答上面的这个问题,首先得搞清楚系统是什么。系统是一组相关联的事物——在一定的时间内,以特定的行为相互影响。系统可能会受到外力的影响,对此产生的反馈方式就是系统的特征。

系统思考的观察方式并不是唯一的解释系统的方式。就像康德说的,人都是戴着有色眼镜生存的,不同的观察方式或许可以突破这一层有色眼镜,使用投影的方式在多个维度综合塑造起系统的真实模样。我甚至希望自己看待同一件事物的视角是相互矛盾的,那样我才会感受到自己的认知是多么有限,这个世界是多么伟大、在有限的生命中充满怎样无限的可能。

从还原论的视角认知的世界是由基本要素构成的,但是系统思考则不同。它的基本理论是,系统由要素、连接,功能或目标组成。一支笔是系统,它的组成要素有笔芯、油墨、笔筒和笔头。笔筒套着笔芯,笔芯镶着笔头,油墨会沿着笔芯和笔头流淌,这就是连接。一支笔的功能可能是写字或画画,然而这些功能是很难从系统内部联想到的,必须让一只手攥住它运作起来,让油墨勾连出运动的线条,力透纸背,入木三分,才能观测出实际的功能。有时候,系统的要素是显而易见的,总能应需要分解,但是分解的粒度却是极难把握的,再加上要素可能是无形的,那么就极有可能找不出所有的要素。这个时候,把注意力转向连接则是更为明智和恰当的选择。系统连接的表现形式多种多样,可能是物质流、信息流,准入条件或约束规则,交易、交谈。在现实中,连接多表现在系列动作上,球员之间更加信任、老师给学生打分等等。如果系统中的连接发生改变,系统会受到很大的影响,此后表现出的行为和原来的行为大相径庭。而犹胜这个的,便是系统的目标或者功能发生改变。因为系统有自组织的特点,目标发生的改变会强制系统内部要素应激,最终会导致结构发生改变,从而表现出令人咂舌的行为。

当我们理解系统不仅仅是要素的集合,而且包含内在连接和功能或目标了之后。接下来就得接受系统是动态变化的这一事实。存量是所有系统的基础,存量是任何时候都能观察、感知、计数和测量的系统要素。存量是对系统中变化量的一种历史记录。那么也就是说存量总是会随着时间变化而改变的,而使存量发生变化的就是流量,流量可以看做瞬时的存量。一旦这样理解系统的结构,系统的动态性也就不言而喻了。存量的改变决定了系统的动态变化速度,也让系统具备了延迟性的特点——在任何环境下,系统都不会马上受影响;即使想要改变系统的行为,也需要一定的时间等待它缓慢生效。对存量的改变是通过控制流量做到的。进入的流量大于出去的流量,系统的存量就会增加;反之,会减少;最理想的情况是存量维持在一个动态的平衡状态。如果我们想要改变系统的行为,就需要找到系统流量的控制点,促进或者削弱控制点(手段),以此达成我们想要系统表现出来的行为或者趋势。

系统根据存量的多少,又可以分为单存量和多存量系统。单存量系统因为只有一个存量,控制点数量较少,系统内在连接较少,所以控制起来不是太难。但是即便如此,我们也要意识到由于客观规律的约束,造成流量的不恒定,系统最终态或多或少会偏离预定的值。而复杂的多存量系统,则会因为存量之间会相互施加对控制点(包括自己的控制点)的影响,变得错综复杂,难以理出头绪。所以更好的梳理方式是观察现在系统结构包含哪些行为,以及触发这些行为的条件。这有点类似知果索因的探究方式:行为就是对控制点施加的影响,而条件则是改变存量的外在表现。比如喝咖啡这样的行为。触发这个行为的条件是我困了。困了是体内能量低于正常水平的外在表现。而能量在这里可以看做存量。

系统思考的3大特征

承认系统是美的,这是我们研究系统的动力。假如一个系统整体是良好的,那么每个部分都是好的。

系统具有适应力(Resilience)的特征。适应力指的是系统在多变的环境下保持自身存在和运作的能力,与之相对的是脆弱性或刚性。或者用KK的话说,适应力就是反脆弱性。面对周遭环境的不确定性,系统会表现出短期振荡、阶段性发作和周期性兴衰,适应力会参与其中让系统振荡收敛,复原。在系统正常运行的情况下,适应力是很难被察觉到的,而系统的稳定程度则比较容易统计出来。适应力在系统超出限度,调节回路被破坏,要素被分解的情况下才能被观察。这也就要求我们在设计系统的时候,不仅要考虑到系统正常运行时的指标,也要考虑到极端情况下,系统自我恢复的能力。系统的适应力不是凭空产生的,它是多个调节回路共同影响之下出现的结果。而且复杂的系统一般都有元调节回路(meta-resilience),甚至是元元调节回路(metameta-resilience),这就让系统具备了很强的自组织能力。

系统的第二个特征就是自组织(Self-orgnization)。自组织指的是系统让自己更为复杂化的能力。自组织往往伴随着被扼杀的动作,主要原因是自组织具有不可预料的特质,引导系统发展出全新的行为模式和系统结构。面对可能的不确定性,现在的人们会感到恐慌,其结果采取打压的态度。但是庆幸的是适应力和自组织是系统的基本特征,不可能被消灭。和适应力类似,可能是很多简单的规则逐步产生系统的自组织能力的。比如,现实中的雪花分形,还有生命游戏(game of life)。

系统的第三个特征是层次性(Hierarchy)。层次性指的是系统和子系统之间包含和生成的关系。子系统能够维持自身,并发挥一定的功能,并服务于一个更大系统的需求,而更大的系统负责调节和强化各个子系统的运作,那么就可以产生并保持相对稳定、有适应力和效率的结构。系统的层次性一般是自下而上进化的,上层的目的是服务于下层的目的,而不是牺牲多数人的目的以维护少数人的目的。层次结构要求整体优化,不能让某个子系统的目的占据上风,也不能有太多中央控制。这就意味着层次结构必须平衡整体系统和子系统的关系。

系统思考的6大障碍

  1. 别被表象迷惑
    不要太关注事件本身,而是得关注系统的长期行为趋势,和触发这些行为的条件,这有助于帮助我们梳理出系统结构。而系统结构是行为的根源。
  2. 在非线性的世界里,不要用线性的思维模式
    线性系统可以被模块化,但是非线性系统通常是不可解、不可拆分的。非线性关系之间的相对优势发生改变,会导致不同回路的主导地位发生改变,导致千奇百怪存量的改变。
  3. 恰当地定义边界
    世界万物是互相联系的,不存在孤立的系统。所以边界的划分也应该依据我们的需求和目的。过窄导致对影响因素分析不足,而过宽要致使信息噪声过大,反而难以找出关键要素。
  4. 看清各种限制因素
    限制是客观存在的,它会限制系统的输入和输出。而且限制本身还是动态的,系统也可能是自身的限制因素。从某种程度上说,找到限制或约束力最大的因素是系统得以生存的基础。可能我们从来没让系统进化的更好,只是不断地打破限制因素,系统自己的特征决定了进化方向。
  5. 无所不在的时间延迟
    系统中的延迟无处不在,为了更好地处理问题,一定的预见性必不可少。不然会错过解决问题的黄金时间。
  6. 有限理性
    我们不得不承认即使知道了全部信息,我们也无法做出完全合理的决策。只有快速构建反馈,可视化结果,让人知道做了决策之后的后果,行为就能发生转变。

系统思考的8大陷阱

  1. 政策阻力
    其中政策阻力表现的是哪儿不痛快堵哪里,病急乱投医,治标不治本。
  2. 公地悲剧
    公地悲剧表现在对公有资源的滥用,由于个体遭受的损失由全体承担。
  3. 目标侵蚀
    目标侵蚀是说关注在系统的坏的表现上,产生降低目标、减少修正行为进而加剧的恶性循环。
  4. 竞争升级
    竞争升级就是囚徒困境中的先发制人策略的概述,直到一方退出或者资源耗竭为止。
  5. 富者愈富
    富者愈富指的是为富不仁的道理,富人会刻意不去承担责任。
  6. 转嫁负担
    转嫁负担是指上瘾行为,依赖外部干预者,而导致系统自身解决问题的能力变弱。
  7. 规避规则
    规避规则是说上有政策,下有对策。
  8. 目标错位
    而目标错误最严重,在错误的目标下,任何回路都只会导致无用的结果。

改变系统的12杠杆点

  1. 数字
    关注系统参数是一种低效的杠杆点,可能短期内有效,但是长期看来不起决定性作用。
  2. 缓冲器
    存量意味着系统比较稳定,但是换个角度看,也可能是一种浪费。而且因为是物理实体,通常不易调节。
  3. 存量-流量结构
    类似于缓冲器。不过这是系统的内在结构(不仅包含存量),因为是物理实体调节起来不太容易。
  4. 时间延迟
    承认系统具有延迟的特点,时间延迟无法消除,也就要求我们顺势而为,配合系统的改变节拍, 放慢增长速度。
  5. 调节回路
    为了改善系统的自我矫正能力,需要增强调节回路的力量。比如:保持信息公开透明。
  6. 增强回路
    减少增强回路的产生成果,可能是更加有力的杠杆点。
  7. 信息流
    从人心的角度看,人们避免对自己的决策负责。信息流的缺失是系统功能不良的常见原因之一。恢复信息流,是比重建系统结构更加经济的方式。
  8. 系统规则
    系统的规则会让系统的行为产生翻天覆地的变化,如果规则倾向于维护一小部分人的利益,减少了来自其他人的反馈,就会触发“富者愈富” 的陷阱,导致自我毁灭。
  9. 自组织
    自组织是进化的机制,而它需要一些原材料,即多样性。消除了多样性,世界将会陷入灾难。
  10. 目标
    很多人身处系统之中,也无法得知系统的目标。系统的目标是一个高杠杆点,而它的目标很大程度上是让自己无限增长。那就需要一个更大的系统把维持平衡作为自己的目标。
  11. 社会范式
    社会公认的观念就是社会范式,这些观念不需要特地强调就能被人所认同。改变社会范式的难度是巨大的,但是你可以积极接触那些拥抱新范式的人,参与宣传当中,避免接触反对这些新范式的人。而且,往往在构建系统模型的时候更容易改变范式,因为你跳出了系统本身,把它作为一个整体来观察了。
  12. 超越范式
    意识到范式也是人类看待这个世界的一种模型罢了。不要纠结真理是否存在,假设所有的东西都可能是错的,为了完成目标,只要去选择合适的手段就好了。

系统的15大生存法则

  1. 跟上系统的节拍
    先去了解系统的真实状况,让事实说话,然后用系统性思考动态地分析问题。
  2. 把你的心智模式展现在阳光下
    用科学的方式检验自己的心智模式,包括价值观。
  3. 相信、尊重并分享信息
    信息是系统运作的重要连接 ,控制信息甚至就是控制了整个系统。比如:政治家办报。
  4. 谨慎使用语言,并用系统的概念去丰富语言
    只有可以被谈论了,那东西才会火(发展),比如:微服务。
  5. 关注重要的,而不总是可以衡量的
    容易衡量的东西一般不会被忘记;但是不容易衡量的东西,比如:质量、幸福感等等就很容易被遗漏,如果设计时不去考虑这些,人们就不会关注,更不会改进。
  6. 为反馈系统制定带有反馈功能的政策
    把学习融入管理当中,构建调整回路的回路。
  7. 追求整体利益
    铭记层级组织存在的目的是服务于底层的。不要过度放大任何部分的重要性。
  8. 聆听系统的智慧
    存在的系统自然有它存在的理由,发挥它现有的自我运行的力量和结构是最好的方式。
  9. 界定系统的职责
    职责不是功能。也不是软件设计方法中单一职责表达的变化。职责就是责任,怎么增强系统的内在责任,就必须让每次决策及其结果之间建立起反馈回路。让决策者很快看到后果,意识到结果的严重程度,这才是优化决策最直接的方式。
  10. 保持谦逊,做一名学习者
    不要假装自己是专家,虚张声势是无法改进自己的方式,因为它只能隐藏问题。面对不确定性,我们要做的不是假装自己知晓一切,还是应该“拥抱失误”,这意味着我们需要搜索、使用和分享“我们到底在哪里失误了”这样的信息。
  11. 庆祝复杂性
    这是一种思考方式的转变。系统的复杂性从来不是问题,没有谁可以控制它,它恰是多样性和统一性的条件,所以这个世界才如此异彩纷呈。正如土地理论(Land ethic)所说“当某件事情倾向于保护生物群落的一致性,稳定性和自然之美,它就是对的,否则就是错的”。鼓励自组织、无序、变异和多样性才是我们应该做的。
  12. 扩展时间的范围
    学习过去的经验,面向未来解决现在的问题,不然会很快会崩溃。
  13. 打破各种清规戒律
    系统思考要求“跨领域”思考,正如区块链技术融合分布式原理、加密学理论、博弈论中的理性经济人那样,跨领域的思考模式会产生颠覆性的发明。
  14. 扩大关切的范围
    世界是普遍联系的,没有任何系统是孤立存在的。你关切的东西,不应该仅仅是自己。“各扫自己门前雪,何管他人瓦上霜”是短视的表现。
  15. 不要降低“善”的标准
    世界上充斥着奇葩猎奇的新闻,因为这些新闻是大都涉及偏离现有的社会范式的故事,人们对这个世界上的坏消息总是更容易相信些,这些坏消息成为了“目标侵蚀”的佐证,从而很轻易地丧失自己自幼学习的普世价值观,转而“同流合污”。

系统思考的方式能引导我们看清问题的本质,这已经是莫大的帮助了。至于做或者不做,这是个人的选择权利,在人类精神的系统关照下,我们应当知道什么该做,什么必须去做。

特征

  1. 日常语言描述
  2. 捕获系统行为
  3. 个数有限

在故事基础部分,我提到用户故事通常是日常或者商务语言写成的句子,这些句子描述了用户在其工作职责范围内想要达成的某个目的以及达成该目的需要的功能(手段)。所以书写用户的故事的句式一般都是:As(用户的角色)… I Want(功能或手段)… So That(目的)。根据用户故事的 INVEST 划分原则中 N (Negotiated 可协商的) 原则,故事包含的是对需求的简短描述,具体的细节需要沟通产出,产出物表现为验收条件。

换言之,验收条件是在开发前的分析阶段输出的,它的作用是补充需求细节。更进一步,验收条件其实有力地消除了用户和开发人员之间的沟通鸿沟。为什么这么说呢?因为验收条件具备两点很重要的特征:

  • 日常语言描述
  • 捕获系统行为

这两点特征促进了参与各方在需求点上快速反馈,如下图:
验收条件-反馈环
在敏捷活动中高效地沟通一直被反复强调,因为不高效的沟通造成的信息误导和返工是精益生产活动中应当极力消除的,所以任何能够促进沟通的方式方法都值得提倡。

除此之外,有限的个数 (2-8 ACs/story) 也是验收条件的一个特征,这也是 INVEST 原则中 E (Estimable 可评估的) 所要求的。所以,也引出了验收条件的一个简明定义——用户故事的 DoD (Definition of Done)。也有人说,一组验收条件定义了用户故事的边界(Boundary)。如果任由用户故事自然“疯长”,范围无限放大,交付怕是遥遥无期。

验收条件会作为业务活动描述的一部分存在于用户故事中,一般会在开发之前准备就绪。在敏捷活动 kick-off 时,由业务分析师(BA)和开发人员(Dev),也可叫上质量保证师(QA)一起逐条澄清验收条件,以便保证开发之前达成共识,减少返工和浪费。在其它敏捷活动如:desk-checks, customer sign-off, UI testing, BDD 中也会重度参与。

格式

Given/When/Then

#Title
Given 用户触发操作之前处于的系统状态
When 触发系统结果的操作
Then 系统预期返回的结果

Verifiable checklists

e.g. [PhoneNumber] 只能包含0-9, +

反模式

模棱两可的陈述

1
2
3
4
5
Given: that I have the search page loaded

When: I perform a search

Then: the search results come back within a reasonable period of time

这里的 reasonable period of time 就是不可测试的,因为没有人可以决定什么才是 reasonable.

合理的改法是:

1
2
3
4
5
Given: that I have the search page loaded

When: I perform a search

Then: the search results come back within 5ms

5ms 之内,这是一个标量,完全可以衡量。

非系统交互

1
2
3
4
5
6
7
Given I have pulled my car up to the valet area

When I hand over my keys to the valet person

Then I receive a paper ticket from the valet person

And I am instructed to hold on to it so I can use it to retrieve my car later.

这里的所有描述都是对现实场景的描述,和系统并无关系,对于开发人员构建系统几乎没有丝毫帮助。这种反模式的修正方法是剔除那些非系统的验收条件,重新梳理用户故事。

非系统输出

1
2
3
4
5
6
7
Given: that I am on the home page

And: I am logged in

When: I navigate to account preferences

Then: I can see my account preferences

这里的 I can see my account preferences 是无法进行断言的,因为这是系统无关的,说得极端些,假如用户闭上眼睛,这个功能就没法通过验收了。

合理的改法是:

1
2
3
4
5
6
7
Given: that I am on the home page

And: I am logged in

When: I navigate to account preferences

Then: my account preferences are displayed

这个时候,我可以检查系统展示了我的用户页面。

非系统异常

1
2
3
Given 我选择了一份图表模板
When 我跳转到显示界面
Then 我发现图表不是我想要的

这里的问题涉及了非系统输出没法进行测试等问题,还有一个显著的毛病是误认为用户自身的误操作也必须反映到用户故事中。这里出错的点是用户自己选错了模板,发现产生的图表不是自己想要的格式,系统是自动无法判别选择模板的正确性的。

when 隐藏到 given

1
2
3
4
5
6
7
Given: that I am on the homepage

And: I navigate to the search

When: I look at the page

Then: the search options are displayed

基本上,这是团队编写 AC 时最容易犯的错误,操作出现在前置条件中,when 中反而不是系统行为了。

合理的改法是:

1
2
3
4
5
Given: that I am on the homepage

When: I navigate to the search

Then: the search options are displayed

最佳模式

1. 可读的

我们希望业务人员审阅和修正验收条件,如果写的内容只有开发人员能懂,我们就失去了获得反馈的机会。使用上述书写格式,可以提高可读性。

1
2
3
4
5
6
7
8
9
Given: that my mobile phone is switched on

And: I have sufficient signal to make a call

When: I dial a number

Then: I am connected to the person I want to talk to

And: incoming calls are diverted to my voicemail

2. 可测试的

参见反模式中合理改法。

3. 实现无关的

验收条件应该是实现无关的,它和用户故事一样,是给业务和开发人员提供交流凭证的一种工具,所以它应该聚焦于功能,而不是功能的展现形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
Given: that I am on the home page

And: I am logged in

When: I navigate to advanced search

Then: the advanced search web page must be displayed

And: a text box labelled "Name" is displayed

And: a text box labelled "Description" is displayed

And: a command button named "Search" is displayed

这里已经框死了必须要使用 text box 实现展示功能,而实际上其背后真正的意图是通过属性字段进行搜索,隐藏了业务含义的验收条件是不可取的。

合理的改法是:

1
2
3
4
5
6
7
8
9
10
11
12
13
Given: that I am on the home page

And: I am logged in

When: I navigate to advanced search

Then: the advanced search is displayed

And: an option to search by name is displayed

And: an option search by description is displayed

And: the advanced search is displayed in accordance with the attached wireframe

换句话说,验收条件本身不应该关注于展现形式,当然,为了便于理解,wireframe 是提供直观素材的更好的方式。

练习

用户故事

1
2
3
作为一名管理员
我想要把一名员工加入系统中
以便管理他们的权限

分析步骤

1. 定义边界

  • 触发添加员工操作
  • 输入员工的详情
  • 验证遗漏或者错误的字段
  • 保存

2. 提炼和细化

  1. 触发添加员工操作
    1
    2
    3
    假如我进入了员工管理系统
    当我进入员工的浏览页
    之后添加员工的操作出现在页面上
    2. 输入员工的详情
    1
    2
    3
    4
    假如添加员工的操作出现在浏览页
    当我调用了添加员工的操作
    那么我可以输入员工的姓名和出生日期
    并且出现了保存操作
    3. 验证遗漏的字段
    1
    2
    3
    4
    假如我没有填写员工的姓名和/或生日
    当我尝试保存
    那么保存不会成功
    并且会有消息显示遗漏的字段
    4. 验证错误的生日日期
    1
    2
    3
    4
    5
    6
    7
    8
    假如我正在添加一名员工的详情
    并且我输入了未来或者早于1900年的日期,或者错误的日期格式
    当我尝试保存
    那么保存不会成功
    并且会有消息显示输入的生日日期无效

    验证列表:
    [日期格式] yyyy/MM/dd
    5. 保存
    1
    2
    3
    4
    5
    6
    假如我正在添加一名员工的详情
    并且我输入了有效的生日和姓名
    当我尝试保存
    那么会有消息显示保存成功
    并且包含该员工详情的页面会呈现
    并且详情中的生日和姓名和之前输入的一致

警告

验收条件并不是唯一澄清和约束用户故事的方式!任何可以提升理解和降低沟通成本的方式方法都值得尝试。比如:用户偏好 —— 希望使用下拉框而不是复选框,往往可以通过添加一条记录在故事中补充这部分信息。另外,一个完整的故事最好能附上线框图,一图胜千言。


进一步阅读
[1] 敏捷团队工作流

0%