学习新的编程语言的最终目的是解决实际问题. 掌握编程语言的过程, 在某种程度上近似学习一种新的工程实践. 不仅解决问题固然可乐, 学习的过程也同样充满了新鲜感, 不过需要谨防的是新鲜感带来的胜任力错觉.
胜任力错觉指的是反复接触新东西, 发现不用花费什么气力就理解了其中所有的内容. 说的简单点, 就是自以为是. 这种胜任力错觉导致最常见的后果是以为掌握了某种技能, 真正开始解决问题时, 要么是半天摸不着头绪, 要么就是处处掣肘. 所以我始终相信, 阅读是一码事, 理解是一码事, 掌握还是另一码事, 所谓一码归一码, 大抵就是这么回事.
以终为始, 方得始终. 老子 (真. 老子, 非我) 也说, 慎终如始, 则无败事. 这里的 "终" 就是目标, 在软件工程中, 有一种实践很好得反映了这种做事方式 -- 测试驱动开发. 借我司的一位牛人的原话: 看一个人会不会测试驱动开发, 不是看他的测试写得好不好, 而是要看他是不是始终从测试出发去解决问题. 脑子里条件反射的就是测试该怎么测? 这种才是测试驱动开发的实质.
学习, 说白了就是一个不会到会的过程, 这里头最难的是学会了什么? 在学习方法上, 我们很多时候喜欢遵循前人的套路, 美其名曰知识体系化. 我承认体系是前人经验和群体智慧的积累, 但是学习体系不代表你具备形成体系的能力, 就像你学习了著名开发框架 (Spring or Rails) 也不会说你能开发这套框架一样. 学习的关键还是发散, 收敛和再发散, 再收敛的渐进过程, 感性的定性分析到理性的定量分析, 在不断丰富和修正认知, 处处用实践检验认知. 这种过程坚持下来, 得到就不单单是知识, 可能是元知识 (方法论) 或者智慧.
看书抄代码是个学习的好方法, 不过书中的例子一般都被加工 (简化) 过, 我们很容易陷入套路中, 谨记胜任力陷阱. 比较推荐的方式, 自己认准一段有用的程序, 反复练习 (也可以每次增加些体系化的功能) 直到娴熟. 在接触新语言时, 不去看一套完整的语言体系, 而是事先把这段程序可能用到的基本类型, 数据结构, 流程控制结构, 模块化和功能组件列出来, 然后去找它们在这门语言中对应的实现.
有目的地试错
我常用的练手程序叫 tree , 功能是 list contents of directories in a tree-like format. 这个程序需要用到的基本构件有:
基本类型(basic type)
1. str
数据结构(data structure)
1. list
2. map
流程控制结构(control flow structure)
- if, else
- recursion
模块化(modulize)
- function
- module/namspace/package
功能组件(function components)
- IO
- File
- 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
- 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.) 的修饰.
- defmodule Tree do
- defp tree_format(parent_dir, dir_name) do
- %{:name => dir_name, :children => []}
- end
- end
defp 定义了一个私有的方法 tree_format , 它是用来格式化目录的. 目录结构是树形结构, 所以很容易递归实现.
- 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 .
结构, 解构
完成目录结构的格式化, 接下来需要做的是渲染这组树状的数据.
- 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) 的功能, 参数解构其实就是其中的一个应用场景.
- %{name: dir_name, children: children}
- matching
- %{:name=>".",:children=> ["tree.exs"]}
- # ->
- dir_name == "."
- children == ["tree.exs"]
渲染的过程也是递归的. 最终返回的是一个加上分支标识前缀的列表
[dir_name | children]
这是一种将 dir_name 前置到 children 列表头部, 形成新列表的做法. 和 Clojure(绝大数 Lisp)中的
(cons dir_name children)
类似.
操作符 | 除了可以前置列表元素, 递归解构也是一把好手.
- 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 .
- 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 , 可以考虑模式匹配实现多态.
- 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, ...} . 这恰恰可以使用模式匹配做动态分派了.
- 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
来源: http://www.tuicool.com/articles/yyAjmaJ