2

不是严谨的思考, 只是梳理一下感受, 最近在动态类型静态类型之间切换有点多, 对照思考.
我的经验基本上是 js, ts 和 ClojureScript 上边, 再有点 Nim 的使用经验.
然后 Go 跟 Haskell 都只是简单尝试过, 没有深入进去.
这些个语言都是自动内存管理的, 所以内存往下深入的我也不讨论了.

数据的表示

动态类型, 介绍数据结构时候说数组, 说字典, 然后就是基本的操作.
基本上只是有一个类型结构, 运行时才能判断具体的类型, 但这个对思维模型来说限制少.
比如考虑一个数组, 就是数组, 什么都可以放进去, 数字, 字符串, 对象, 正则, 嵌套数组也行.
在实现层面, 我喜欢叫这个 uni-type, 因为内部就是一个结构包含很多信息来表示各种类型.
编程的时候, 考虑的就是有这么个结构, 有这么个 value, 至于 value 是什么, 动态的, 可以随意放.

静态类型, 考虑就很多了, 特别是内存布局方面的,
Nim 里边就是 object(类似 struct), seq, array, list, 多种的结构.
每个结构会对应的到一个内存的布局的方式. 细节具体怎么布局, 我了解不彻底, 但是模糊地对应上.
特别是静态类型编码的时候不同结构之间性能的差别, 比较容易显露出来.
我用错了结构的时候, 数组的地方用链表, 访问速度的差距马上就出来了.
而且结构对类型限制明确, int 数组就不能有 string 了, 这个也是明显的.

从业务编码的角度来说, 用动态类型来模拟一些业务逻辑, 通常还是比较轻易的.
而要用静态类型, 经常需要有一些思考, 用怎么样的类型来表示, 受到什么样的限制.
定义了结构, 还有怎么访问怎么遍历的问题.
一般说比如 js 性能优化较全面了, 一般场景都不会去考虑不同写法的性能差别了,
而静态类型, 表示的方式, 访问的方式, 都比较容易对性能造成影响.
静态类型更贴近机器, 或者说用静态类型, 就更加需要熟悉编码器的各种行为, 以便生成高性能代码.

Clojure 这边还有个较为特殊的例子, 就是 Vector 类型 HashMap 类型内部是用 B+ 树表示的.
一般动态类型语言不会去强调这种内部的实现, 但是 Clojure 出于性能原因高调宣传了一下.
React 这边, 不是被 immutablejs 搞得沸沸扬扬的, 之前大家也不太注意内部实现什么样子.
我用 Nim 时间长了一点发现这中抽象方式在静态类型当中还是蛮普遍的, 数据结构嘛, 链表, B 树...

而且有个不小的不适应的点, 在动态类型当中, 一个深度嵌套的数据结构, 直接打印就好了,
比如一个大的 JSON, 打印的时候可以就递归地格式化掉. prettier 一下就能看.
但是静态类型, Nim 当中的例子, 有时候数据就是不知道怎么打印的,
因为复杂数据没有一个 $(表示 toString) 函数, 那么就无法打印.
这个在 Haskell 当中也是, show 有时候通过 derive 继承过来, 有时候就不行.
由于这一点, 静态类型就比较容易隐藏掉一个数据的内部实现了.
而动态类型, js Clojure 的数据, 一般就是直接递归打印出来.
js 用 class 的话我记得能重新定义掉, 不过 React 这边也不乐意去用 class.
而且 js 程序员大多也习惯了 Console 当中层层展开直接查看数据的.
我觉得有封装能力对于程序结构来说是更好的, 虽然便利性确实有折扣.

通用性

动态类型, 不同的程序通过字符串交换数据的时候, 简单粗暴, JSON stringify/parse 就搞定了.
JSON 现在是非常通用的结构了. Clojure 那边也是认为这是动态语言巨大的优点.
这就是程序基础的结构, 跨程序跨语言都共通的结构, 这才有广泛的通用性.
而 nil 在当中扮演的角色也比较重要, 因为外部来源的数据很可能就是缺失内容的, 大量的 nil.

静态类型在数据接收传递上就存在限制了, 大量的 nil, 判断起来就很麻烦.
protobuf 我没用过, 调研的时候看说的, 二进制数据需要类型文件才能解析,
而且结构也是严格定义的, 不能错. protobuf 还额外加上限制, 不能删除字段.
从编码器的角度看这边是比较好理解的, 为了方便生成代码, 这些外来的数据都应该尽量准确单一,
不单一不稳定的话, 就需要额外生成很多的代码来处理特殊情况, 就很麻烦.
Nim 的例子我试了, 但这个主要是靠 macro 在简单场景能提供很方便的写法,
实际用的话, Nim 处理一大堆的字段, 先用 JSON 获取, 再读取, 也很啰嗦.

代数类型, 算是另一个方向吧. 按照我前面的思路, 它的表达能力偏向于动态类型,
但是相比于硬件的限制, 代数类型感觉更多是收到数学定义的限制,
比如 data A = B | C 这种或的逻辑, 硬件上表示会啰嗦不少,
而代数类型通过内置的语法, 无论创建, 操作, 还是类型检查, 都比单纯静态类型方便.
另外有名的例子就是 Maybe string 这样的类型来替代 nil 了.
我没什么使用经验, 不好判断这个实用性.

数据校验.

动态类型, 默认也没什么校验的, 数据不对, 执行的时候报错, 就这样了.
需要校验的话都是动态地按照规则去校验, 比如 Clojure 用的 spec, js schema 之类的.
动态有个好处是这中间的逻辑可以用编程语言直接去表达出来, 非常灵活.
and or 这样的逻辑操作随便用, if switch 也能用, 其他逻辑也是,
这就有了比较大的发挥的空间了, 封装成不同的类库.

静态类型, 从生成代码的角度, 这个代码能运行就是已经经过校验的,
或者应该说, 程序能运行, 就是说编码的数据满足了一些编码器要求的限制的.
seq[int] 当中是 int, 错的话代码编译就会报错. 除了业务, 一般也没什么需要类型校验的.
但这个, 相对来说也就是编码器要的那种程度, 对于业务来说是比较死板的.

另外有个比较花哨的, TypeScript, 实际运行允许 any, 就显得非常特殊了.
我倾向于认为 ts 就是个 type checker, 而不是当做一个 compiler.
ts 当中也借鉴了代数类型做了一些 union type, product type, 非常强大,
只是说跟前两者相比, 是一个非常奇特的思路了.
代数类型, Haskell 有个 quickcheck, 能通过类型自动生成随机数据测试, 听说是非常强大的,
Clojure Spec 部分山寨了这样的功能, 依赖的是运行时加上 macro 一些能力.
静态类型这边要这么做, 就要反射机制了, 这个我不熟悉... 这整块东西感觉水就比较深了.

数据方法

动态类型, 还是前面 uni-type 的想法, 数据很多时认为是一个类型, 没得区分的.
具体运行的话, 就有个专门的字段比如 .kind 标记, 用来判断类型,
然后针对不同类型要调用不同的方法的话, 也就能做针对性的操作了.
动态语言, 多态靠的对象继承的方式来实现. 绑在类上边有个函数方法.
当然, 这个在 React Clojure 生态里边, 这种就比较少了.
特别是数据还以 JSON 形式到处分发, 更多的还是用 .kind 标记, 再调用不同函数.

静态类型这边, 当我使用的 Nim 的时候, 就发现这里多态就很自然有个方案, 因为有类型,

proc f(a: int): bool

proc f(a: string): bool

实际调用 a.f()(Nim 里边这是 f(a) 的缩写)的时候, 根据 a 的类型就有对应的方法.
我联想起来 Haskell 定义 class instance 的时候类似也是这样的,
OOP 不是我熟悉的领域我也不好说了, 但是这个场景有类型, 解决方案就很自然.
而动态类型, 我定义一个 join, 就要用判断不同类型再针对处理了, 也不方便扩展.

我觉得这一点对我们理解数据比较有影响, 因为数据往往是要被操作的.
我是把数据认为是树状的一大个, 然后需要函数动态地去判断, 然后再操作呢?
还是说认为数据一块一块相互拼凑, 但是各自有清晰的类型, 然后有各自的方法?
还是说跟原始的面向对象那样, 就是一个对象, 接受消息, 发出消息?
我不好说这些观念互斥, 但是比如说你设计一个简单的框架, 别人按照框架来快速完成功能,
那么在这个框架当中, 别人怎么去理解数据这个事情, 当然还有状态, 这中间的关系和功能?
因为我是 ClojureScript 用得比较习惯, 我就更认为是树状的一大串.

抽象能力

上边梳理的基本还是零碎的想法, 最终还是会回到抽象能力这个事情上来.
当然开发当中有不同的场景, 不同的方案并不是说某个就是不对的, 它最少也是有一个适合的场景.
但是这种, 对于思考业务的差别还是挺大的, 反应在不同的编程语言当中.

比如 Clojure 当中, 动态的, 而且强调不可变数据, 就会导致全局存储树形的数据结构,
我看待状态的时候, 在这类场景当中就是一棵可能是很深的树, 然后判断.
因为是动态的, 也是树, 我就会非常随意地允许一些配置化的数据随处出现,
我也不那么在乎这个结构是不是个单元, 需要的时候, 我就写个函数, 动态处理一下就好了.

而 Nim 当中, 定义类型, 枚举, 结构体, 这些概念就很基础,
我定义一个函数, 也要想要函数的操作对象首先是谁, 哪些可变哪些不可变.
这个跟面向对象那种思路也相近一些, 他能被怎样操作有哪些方法, 都会想一想.
然后如果有深一点的数据, 很明显就是这些小的结构的组合了.

我用动态类型的话, 比较容易会淡化掉这种结构性的考虑, 因为创建新的结构成本很小,
不用说两个相似的数据, 多一个属性少一个属性, 考虑是不是去复用,
本来大家就是 uni-type 了, 大家都是一样的, 只是内部包含的信息动态有差异.
这个随意性, 对于性能来说是不好的, 但是编码当中便利是存在的.
静态类型, 当然, 性能好.
此外, 还有个情况, 就是有类型推断参与的场景,
由于类型推断依赖一些假设, 这中间的情形就随着变复杂. 就感觉挺混搭的.
比如 TypeScript 依据开头创建的字段和元素简历类型了, 但是后边编码可能又被推翻掉.

再展开点想, 如果只是为了完成业务, 甚至都不限于使用某个编程语言,
那么我们描述的业务用的 DSL 当中, 为了也是专门定制的数据,
这个时候数据类型啊, 操作方法啊, 这里的数据该往怎么样的形态上演变?

我没有个清晰的结论. 本能得我会希望业务上我能动态类型随便上,
当然我也知道类型推断提供的类型支持, 对于避免程序漏洞非常强力,
而且一些基础的数据结构, 能抽象出来封装好称为一个类型, 好处也很多.
大概后边要对 Haskell 那样的体系还是要增加一些了解, 反过来帮助理解动态类型.


补充...

后续的学习, 再清理了一些疑惑.
关于 Nim 的多态, 属于 ad-hoc polymoprhism, 翻译过来即时多态?
Haskell 当中基于 type class 有这样类似的 ad-hoc polymorphism 语法.
然而 Haskell 当中更多还是 parametric polymorphism,
在 Nim 当中泛型是对应这种 parametric polymorphism 的.
Haskell 当中出于研究目的, 设计了很多种类型抽象的能力. Nim 简陋很多.
同时有这些类型的约束, 和各种类型的抽象方式, 也就需要更多的 trick 来保证灵活性了.


题叶
17.3k 声望2.6k 粉丝

Calcit 语言作者