引子的引子

金庸小说里的大侠们在行走江湖时,向来都是衣来伸手饭来张口,一不小心心情郁闷了还可以来个金盆洗手、笑傲江湖,就是从来不会考虑钱的问题。我不是大侠,生活也不是小说, “退隐闭关”半年啃了几十本书后,生活还是要继续。这几日满京城地找工作,各种笔试面试聊技术聊人生,也让我对自己的技术体系和职业规划做了一次全盘扫描,快哉快哉。

引子

前几日面试了一家专注云计算的创业公司易云捷讯,一面是公司创始人从美国打来的越洋电话,聊什么我已经记不太清楚了,其中有一道题目是关于 Fibonacci 数列的,这个恰好问到了我的 G 点上,于是我就将我在《SICP》上学到的什么快速乘法啊、尾递归啊、迭代和递归啊啥的和盘托出,大大地吹嘘了一番,总之感觉还不错,算是了给面试官的一个小惊喜,就这样胡乱通过了一面。一面之后邮件沟通被分配了一道编程题目,大意如下1

在一个国际象棋 \(N \times N\) 大小棋盘上,有一个棋子 "马 (Knight)",处在任意位置 \((x, y)\) ;马的走法是日字型,即其可以在一个方向上前进或后退两格,在另一方向上前进或后退一格。请编程找出马如何从位置 \((x, y)\) 走到棋盘右下角 \((N, N)\) 的位置的步骤。例如:假设棋盘大小为 \(3 \times 3\) ,马处在 \((1, 2)\) 位置,马只需要走一步,即 \((1, 2) -> (3, 3)\) 即可到达目的地。编程语言推荐的是 Python,程序能输出一种可行的方法即可(无需找出所有方式),还有一些软件工程的要求诸如文档、注释、代码风格这些 “无关痛痒”的东西。

我是第一次在应聘时碰到这种不限时间自由 rush 的题目,坦白的讲,我还是比较喜欢这种形式的题目的。纸上谈兵的笔试有点八股,ACM 般的机试时间又太紧,面对面的聊天或者刺刀见红般的白板写代码算是比较好的了解一个人技术水平的手段,但是这三种方式的考核方式其实都和真实的工作流程相左;而这种 rush 类型的题目基本上可以反应一个人工作能力 —— 你可以利用 Google、可以去查算法书籍、可以在有限的时间内再去学习一些技术,唯一要求的就是诚信。

我看到此题的第一想法就是暴力试探法,因为对于 knight \(p(x, y)\) 而言, \(p(x-1, y)\)\(p(x + 1, y)\)\(p(x, y-1)\)\(p(x, y + 1)\) 四个方向上相邻的点都是可达的。按此思路只需要“步步为营,稳扎稳打”就可以到达“胜利的终点” \(p(N, N)\)

显然,这种策略不是对方想要的结果。适逢夜深燥热、大脑混沌,且先睡去,待次日云淡风清,再做打算。果不其然,早起在“响应大自然呼唤”的时候,我想到了理论上的最佳解决方案,不但能给出一条可行的路径,而且这条路径保证是最短的。聪明的你或许已经想到,没错,这个题目本质上就是一道 Unweighted Undirected Shortest Path 的图论问题。转换的方法很简单:从左到右从上到下,将 \((N \times N)\) 的棋盘上 \(N \times N\) 个 point 顺次编号为 \(0, 1, ... N^{2}-1\) ,对于棋盘上的一个点 \(p(x, y)\) ,按照 knight 的走法,其下一步到达的点最多有 8 个:\(p1(x - 1, y - 2)\)\(p2(x - 1, y + 2)\)\(p3(x + 1, y - 2)\)\(p4(x + 1, y + 2)\)\(p5(x - 2, y - 1)\)\(p6(x - 2, y + 1)\)\(p7(x + 2, y - 1)\)\(p8(x + 2, y + 1)\) ,所有这些点的集合构成了点 \(p\) 在 unweighted undirected graph 中的邻接表。接下来 BFS 算一下最短路径(由于是无权图,因此还是不劳烦 Dijkstra 大侠的高招了,简单的 BFS 就够了),再做一些简单的输入输出和错误处理就 OK 啦。那么最后的一块骨头就是 Coding 了。

我最后大概花了 15 个小时左右完成了 C++/Lisp/Python 三种不同语言的程序,三者的 Coding 时间比大概是 5:2:3,这也是我第一次用不同的语言来写同样的程序,原本设想起来不算太难的工作,写完后还是很有收获的2,勿在浮沙筑高台,Coding 这种工匠手艺活,眼高手低是最要不得的。

文既至此,我就顺便谈谈语言学习的问题,高贤见笑后,如有时间,万望不吝赐教。

C++

按照我所了解的当代中国本科计算机教育,大多人工科学生学习的第一门编程语言应该是 C,接下来如果还有需要的话,就是 C++ 或者 Java 了。不幸的是,鄙人也是这样过来的,幸运的是,鄙人天资愚鲁,学了半年 C++ 后知难而退,转战了实战主义的 Shell Script 和 Python,这一年来闹中取静,为了探寻 MapReduce 算法模型的本原,啃了几本 Lisp 的书,回过头来,总算对 C++ 的诸多繁杂特性有了一些全局性的认识,这种认识来自于跨语言的佐证和思考,而非来自于 C++ 语言本身。事实上 C++ 中的很多概念在 C++ 中是无法学到通透的,比如:

  • C++ 11 的 lambda:你可以不知道 Alonzo Church,也可以不会 Lambda calculus3,但是如果你不知道什么叫 Higher-order function4 的话,你怎么好意思说,你会 lambda?lambda 仅仅是匿名函数那么简单吗?匿名函数有何用?匿名函数的递归问题怎么解决5?STL 里的 functor 究竟是语言机制设计的经典还是为了弥补语言不足所贴的狗屁膏药?
  • STL:STL 几乎就是 C++ 经典库和设计的代名词,通过迭代器将算法和组件分离6,力求达到性能和代码重用的双重顶点。
    • 可惜你不知道的是,迭代器并不是一个高层次的抽象机制,迭代器的本质是一种迭代遍历操作,至于通过什么手段来迭代遍历,这些本不应该是使用者所应该关心的细节问题。所以对于下面的这段 C++ 伪代码:
  • 其高阶抽象代码应该是:
  • 稍微了解一点 Lisp 的读者都能想到如下的等价伪代码7
  • 每次你写 v.begin(), v.end() 这样的代码时,你就不知不觉地降低了自己的抽象层次,使自己脱离问题域而转向去纠结于实现域,不要小看这种力量, 软件工程的一切欢乐和痛苦,只是聚沙成塔的两个极端而已。
    • 事实上 STL 本身包含很多函数式编程的思想,比如说 functor 这种组件,其实质是将在 C++ 中作为 second-class 的函数通过类封装的手段提升至 first-class,如此一来,函数的核心操作摇身一变成为 functor 的时候,就可以像函数式语言里面的 higher-order function 一样,可以用类成员变量来模拟实现闭包,可以被当做普通参数传递返回(这样就不用费力去写令很多新手语法不过关的函数指针了),甚至可以通过 std::bind1st / std::bind2nd 这种奇技淫巧实现一个蹩脚的线性代数级别的函数映射与变换8
    • STL 里面大量的算法都是基于迭代器的抽象而进行序列的批量化操作,同种算法多种容器的核心技术是 基于 C++ 模板实现的静多态 ,"It is better to have 100 functions operate on one data structure than 10 functions on 10 data structures",从这个角度上来讲,STL 算法和 Lisp 中针对 sequence 类型数据的各种函数( mapcar / remove / remove_if / member 等)有异曲同工之妙。
    • 最后来八一八 STL 之父 Alexander Stepanov,其实人家是莫斯科大学数学系毕业的高材生,所以 STL 背后有着很深的数学思想,"Elements of Programming"或许是解开这个谜题的钥匙。另外,Alexander 是反对 OOP 的。
  • 泛型与模板:这大概是 Modern C++ 中最重口味的话题了,也是很多 C++ 初学者的噩梦。我认为 C++ 模板足够强大,但同时也足够扭曲且非人道,布满了大大小小的地雷和陷阱。探究起来,C++ 模板之所以有那么多坑,其历史原因在于 C++ 模板是一种被发现而非被发明的技术9。C++ 最初引入模板的动机非常简单,无非就是写一些通用的 min / max 函数和一些简单的泛型类,但是人们后来发现 C++ 模板竟然是图灵完备的,这件事极大的刺激了 C++ 程序员的神经,于是乎,一个又一个神乎其技的 ad hoc 的模板编程的奇技淫巧被挖掘出来,这些奇技淫巧分布在 C++ 标准库的各个角落,而这些奇技淫巧本身也成了许多 C++ 程序员绕不开躲不过的必修课。C++ 的学习就像练剑一样,练到一定境界总会碰到这样那样的瓶颈,这个时候很多人就会认为自己功力不够,或者是练得不够刻苦,于是乎找来一本又一本的“武功秘籍”10 更加刻苦地练剑。殊不知,如果方向不对,再怎么努力刻苦也难免事倍功半。你可知道,繁杂的 C++ 模板特性的背后,其本质到底是什么?
    • C++ 模板的本质在于用编程的手段显式地控制编译器的代码生成。没错,聪明的你已经想到,Lisp 的 macro 做的也是同样的事情。但是不同于 Lisp 的 macro,由于 C++ 模板的先天不足和 C++ 静态类型系统的限制,C++ 在语言层面上对模板编程的支持非常有限。荣耀先生有一篇非常精炼的PPT《C++模板元编程技术与应用》,基本上概括了 C++ 模板编程的核心机制和语言实现,我摘录了一些如下:
      • 模板元编程使用静态 C++ 语言成分,编程风格类似于函数式编程,其中不可以使用变量、赋值语句和迭代结构等。
      • 在模板元编程中,主要操作整型(包括布尔类型、字符类型、整数类型)常量和类型。被操纵的实体也称为元数据(Metadata)。所有元数据均可作为模板参数。
      • 由于在模板元编程中不可以使用变量,我们只能使用 typedef 名字和整型常量。它们分别采用一个类型和整数值进行初始化,之后不能再赋予新的类型或数值。如果需要新的类型或数值,必须引入新的 typedef 名字或常量。
      • 编译期赋值通过整型常量初始化和 typedef 语句实现。例如:
        • enum { Result = Fib<N-1>::Result + Fib<N-2>::Result};
        • static const int Result = Fib<N-1>::Result + Fib<N-2>::Result;
      • 成员类型则通过 typedef 引入,例如:
        • typedef T1 Result;
      • 条件结构采用模板特化或条件操作符实现。如果需要从两个或更多种类型中选其一,可以使用模板特化,如前述的 IfThenElse11
        • 静态 C++ 代码使用递归而不是循环语句。递归的终结采用模板特化实现。如果没有充当终结条件的特化版,编译器将一直实例化下去,一直到达编译器的极限12
      • 而正是由于底层支撑性语言机制的匮乏,使得 C++ 模板编程非常的冗长、丑陋,甚至有些扭曲乃至非人道13。我以为,用一门连 IfThenElse 都要靠 Hack 去实现的子语言去写高阶代码,和用汇编语言去写高级数据结构是差不多的。所以你去看 STL 的代码,看 std::binary_function ,你会发现大量的 typedef 做类型推导。可是你想过没有,类型推导真的是必须的吗?未必。这么多 typedef 完全是拜 C++ 的静态类型系统所赐。我不是说静态类型不好,事实上关于静态类型和动态类型历来都是学术界和工业界乐此不疲的热门口水战。我想说明的是, 有时候你想要舞蹈的时候,要低头看看,你的脚上是否带着不必要的镣铐 。C++ 的静态类型系统对于泛型编程而言,就是这样的镣铐。
  • 引用、指针、const、static 等:除了以上比较“重口味”的 C++ 语言特性,C++ 里还有各种各样的语言小尾巴,而且这个尾巴一般都拉的特别长。当然,尾巴长的好处之一就是可以养活很多语言专家,什么 effective 啊、exceptional 啊、faq 啊啥的,在所有的编程语言中,C++ 这点绝对是独树一帜。其实每个语言特性的背后都有值得深究的知识, 没有任何事情是想当然的14 const 够简单了吧?可是你知道 const pointer 和 pointer to const 的区别吗?你知道什么时候用 const 引用传参什么时候返回 const 引用什么时候返回值吗?你知道 const 成员函数吗?你知道为什么会有初始化成员列表的存在吗?再来说说引用这个概念,其本质上就是一种受限指针加上编译器层面上的语法糖修饰,按理说不太难,但是什么时候传引用返回引用确是值得深究的好问题,搞清楚了这点,你就会搞明白 C++ 中的 copy constructor/copy assignment operator,Java 中的 Object.clone() ,Python中的 is 、和 Lisp 中的 eq / eql / equal 。传引用 / 指针还是传值涉及到深刻的程序语言原理,并不是你想象的那么简单而已。
  • 以上谈了这么多,读者可能会问,既然 C++ 如此繁杂,还要不要学习 C++?学,当然要学,否则你怎么批判呢?怎么学?批判地学。要去学习语言机制的根源和本质而不要迷失在语言特性的森林里15
  • 最后,还是回到面试题上,还是放上鄙人的 C++ 代码,也好和 Lisp/Python 版的程序做一个小对比:
#include <queue>
#include <limits>
#include <iostream>
#include <vector>
#include <map>
#include <functional>
#include <queue>
#include <list>
#include <cstdlib>
using namespace std;

struct vertex {
  int index;                  /// the vertex index, also the vertex name
  vertex* prev;               /// the prev vertex node computed by bfs and bfs_shortest
  int dist;                   /// the distance to the start computed by bfs
  /// and bfs_shortest
  vector<vertex*> adj;        /// the adjacency list for this vertex

  vertex(int idx) : index(idx) {
    reset();
  }

  void reset() {
    prev = NULL;
    dist = numeric_limits<int>::max();
  }
};

class graph {
public:
  graph() { }
  ~graph();
  void add_edge(int start, int end);
  void bfs(int start);
  void bfs_shortest(int start);
  list<int> get_path(int end) const;
  void print_graph() const;

protected:
  vertex* get_vertex(int idx);
  void reset_all();
  list<int> get_path(const vertex &end) const;

private:
  /// disable copy
  graph(const graph &rhs);
  graph& operator=(const graph &rhs);

  typedef map<int, vertex*, less<int> > vmap;
  vmap vm;
};

graph::~graph() {
  for (vmap::iterator itr = vm.begin(); itr != vm.end(); ++itr) {
    delete (*itr).second;
  }
}

/**
 * return a new vertex if not exists, else return the old vertex, using std::map
 * for vertex management
 *
 * @param idx vertex index
 *
 * @return a (new) vertex of index idx
 */
vertex* graph::get_vertex(int idx) {
  /// cout << "idx: " << idx << "\tvm.size(): " << vm.size() << endl;
  vmap::iterator itr = vm.find(idx);

  if (itr == vm.end()) {
    vm[idx] = new vertex(idx);
    return vm[idx];
  }

  return itr->second;
}

/**
 * clear all vertex state flags
 *
 */
void graph::reset_all() {
  for (vmap::iterator itr = vm.begin(); itr != vm.end(); ++itr) {
    (*itr).second->reset();
  }
}

/**
 * add an edge(start --> end) to the graph
 *
 * @param start
 * @param end
 */
void graph::add_edge(int start, int end) {
  vertex *s = get_vertex(start);
  vertex *e = get_vertex(end);
  s->adj.push_back(e);
}

/**
 * print the graph vertex by vertex(with adj list)
 *
 */
void graph::print_graph() const {
  for (vmap::const_iterator itr = vm.begin(); itr != vm.end(); ++itr) {
    cout << itr->first << ": ";
    for (vector<vertex*>::const_iterator vitr = itr->second->adj.begin();
         vitr != itr->second->adj.end();
         ++vitr) {
      cout << (*vitr)->index << " ";
    }
    cout << endl;
  }
}

/**
 * traversal the graph breadth-first
 *
 * @param start the starting point of the bfs traversal
 */
void graph::bfs(int start) {
  if (vm.find(start) == vm.end()) {
    cerr << "graph::bfs(): invalid point index " << start << endl;
    return;
  }

  vertex *s = vm[start];
  queue<vertex*> q;
  q.push(s);
  s->dist = -1;

  while (!q.empty()) {
    vertex *v = q.front();
    cout << v->index << " ";
    q.pop();

    for (int i = 0; i < v->adj.size(); ++i) {
      if (v->adj[i]->dist != -1) {
        q.push(v->adj[i]);
        v->adj[i]->dist = -1;
      }
    }
  }
}

/**
 * the unweighted shortest path algorithm, using a std::queue instead of
 * priority_queue(which is used in dijkstra's algorithm)
 *
 * @param start
 */
void graph::bfs_shortest(int start) {
  if (vm.find(start) == vm.end()) {
    cerr << "graph::bfs_shortest(): invalid point index " << start << endl;
    return;
  }

  vertex *s = vm[start];

  queue<vertex*> q;
  q.push(s);
  s->dist = 0;

  while (!q.empty()) {
    vertex *v = q.front();
    q.pop();

    for (int i = 0; i < v->adj.size(); ++i) {
      vertex *w = v->adj[i];
      if (w->dist == numeric_limits<int>::max()) {
        w->dist = v->dist + 1;
        w->prev = v;
        q.push(w);
      }
    }
  }
}

/**
 * get the path from start to end
 *
 * @param end
 *
 * @return a list of vertex which denotes the shortest path
 */
list<int> graph::get_path(int end) const {
  vmap::const_iterator itr = vm.find(end);

  if (itr == vm.end()) {
    cerr << "graph::get_path(): invalid point index " << end << endl;
    return list<int>();
  }

  const vertex &w = *(*itr).second;

  if (w.dist == numeric_limits<int>::max()) {
    cout << "vertex " << w.index << " is not reachable";
    return list<int>();
  }
  else {
    return get_path(w);
  }
}

/**
 * the internal helper function for the public get_path function
 *
 * @param end
 *
 * @return a list of vertex index
 */
list<int> graph::get_path(const vertex &end) const {
  list<int> l;
  const vertex *v = &end;

  while (v != NULL) {
    l.push_front(v->index);
    v = v->prev;
  }

  return l;
}

class chessboard {
private:
  struct point {
    int x;
    int y;

    point(int px, int pb) : x(px), y(pb) { }
  };

public:
  chessboard(int s);
  void solve_knight(int x, int y);

protected:
  bool is_valid(const point &p);
  point next_point(const point &p, int i);

private:
  graph board;
  int size;
};

/**
 * constructor, build a underlying graph from a chessboard of size s
 *
 * @param s
 */
chessboard::chessboard(int s)
  : size(s) {
  for (int i = 0; i < size; ++i) {
    for (int j = 0; j < size; ++j) {
      int start = i * size + j;
      point p(i, j);

      for (int k = 0; k < 8; ++k) {
        /// the next possible knight position
        point np = next_point(p, k);

        if (is_valid(np)) {
          int end = np.x * size + np.y;

          /// add edges in both directions
          board.add_edge(start, end);
          board.add_edge(end, start);
        }
      }
    }
  }
}

/**
 * find and print a path from (x, y) to (size, size)
 *
 * @param x
 * @param y
 */
void chessboard::solve_knight(int x, int y) {
  int start = (x-1) * size + (y-1);
  int end = size * size - 1;

  board.bfs_shortest(start);
  list<int> l = board.get_path(end);

  int count = 0;
  for (list<int>::const_iterator itr = l.begin(); itr != l.end(); ++itr) {
    cout << "(" << *itr/size + 1 << ", " << *itr%size + 1<< ")";
    if (count++ != l.size() - 1) {
      cout << " -> ";
    }
  }
  cout << endl;
}

/**
 * whether or not the point is valid in the chessboard
 *
 * @param p
 *
 * @return true for valid
 */
bool chessboard::is_valid(const point &p) {
  if (p.x < 0 || p.x >= size - 1 || p.y < 0 || p.y >= size - 1) {
    return false;
  }
  return true;
}

/**
 * the next possible position, every has 8 next possible position, though not
 * all 8 position is valid
 *
 * @param p the original knight position
 * @param i
 *
 * @return
 */
chessboard::point chessboard::next_point(const point &p, int i) {
  int knight[8][2] = {
    {2, 1}, {2, -1},
    {-2, 1}, {-2, -1},
    {1, 2}, {1, -2},
    {-1, 2}, {-1, -2}
  };

  return point(p.x + knight[i][0], p.y + knight[i][1]);
}

int main(int argc, char *argv[])
{
  if (argc != 4) {
    cerr << "Wrong arguments! Usage: knight.bin N x y" << endl;
    return -1;
  }

  int N = atoi(argv[1]);
  int x = atoi(argv[2]);
  int y = atoi(argv[3]);

  chessboard chess(N);

  chess.solve_knight(x, y);

  return 0;
}

Lisp

Lisp 是一门阳春白雪的语言,前两天我去面试一个 Linux 后端开发的职位,面试官看到我的简历还当面问我“Lisp 是一个什么东西”……Lisp 最广为人知的特点,大概就是——括号了吧。因此 Lisp 除了代表 "List Processing",还有一个别名 "Lots of Irritating Superfluous Parentheses"。括号的背后其实是 S-expression。诈看上去,S-expression (+ 1 2) 比之于我们熟悉的 1 + 2 确实要晦涩一点,但是你要明白的是,我们之所以比较喜欢 1 + 2 这种形式的写法,那完全是我们小学教育的错16。想想高等数学吧,函数 \(f(x, y, z)\) ,翻译成 Lisp 的 S-expression l就是 (f x y z) ,但是如何翻译成 1 + 2 形式的语句呢?事实上在 Lisp 发明之初,确实有人指出说 S-expression 写起来特别的别扭,John McCarthy 也曾经试图将 S-expression 转换成 M-expression的形式,可是后来人们发现 S-expression 所带来的好处远远超出其微末的学习成本, M-expression 的计划也就无疾而终了。S-expression是 Lisp 程序员一切欢乐与痛苦的来源17

  • S-expression 带给 Lisp 的第一个好处是语法的简单一致性。显而易见的例子就是 Lisp 中没有类似于 C 语言中的运算符优先表。
  • S-expression 带给 Lisp 的第二个好处是 Homoiconicity,体现在 Lisp 中,就是 "code is data"18
  • 基于 "code is data",S-expreesion 带给 Lisp 的第三个好处就是强大的 macro。前面我们曾经讲到,“C++ 模板的本质在于用编程的手段显式地控制编译器的代码生成”,也就是所谓的元编程 meta-programming。我们还提到,C++ 在语言层面上对 meta-programming 的支持非常匮乏,因此才会有各种各样的 workarounds(effective/exceptional 中称为 idioms 或者 techniques)。与 C++ 模板不同,Lisp 的 macro 可以调用几乎所有 Lisp 的语言机制。正式由于 S-expression 的存在,使得 Lisp 代码本身不经解析就是一颗完美的对编译器极度友好的抽象语法树当我们写 Lisp macro 的时候,我们其实是在和 Lisp 编译器交谈 ,我们告诉 Lisp 编译器,那些参数需要求值19,那些代码需要循环执行 ( do / dolist / dotimes ),那些结构需要定义 getter/setter(defstruct) 等等。要知道,Lisp 的老本行就是 List Processing,而任何合法的 Lisp 的代码本身也一个 List,用 Lisp 的能力来操作自身的代码,进行代码变换,这就是 Lisp 的 macro。
    • 好学的读者可能会问,元编程到底有什么用?其实很简单,当你在写一句句 C 语言代码的时候,你就已经在用元编程了。广义上来讲,任何能够控制代码生成的编程方法都可以看作是元编程,元编程其实是编译器的主要工作职能。不明白?好吧。我们要从遥远的汇编时代讲起。没有 C 语言(高级语言)之前,人们在汇编语言的酱缸中浸淫。终于有一天,有那么几位智者大神跳出来,总结出说编程语言的控制结构无非就是顺序、 选择、循环三种,于是就有了 if ,有了 for / while ,从此程序员就快乐写写 if / for ,抛弃了汇编,因为有一个叫做编译器的助手可以自动生成 if / for 的底层汇编代码。如果说编程是为了解决重复性的工作,那么元编程就是为了解决重复性的编程代码工作。
    • Lisp 的 macro 所带来的元编程能力与其他语言相比,其最大的特点在于 Lisp 的 macro 元编程是可扩展的,也就是说,我们可以通过 Lisp 的 macro 写一些库,而这些库和语言本身的机制能够很好的融合在一起;其余的语言诸如 C++/Java,其语言机制的扩展则需要进行漫长的标准化进程。

除了以上,Lisp还有一些非常独特的优点,使得这么古老的阳春白雪般的语言虽然尚未蓬勃,但注定不会消亡:

  • 快速反馈的交互式开发模型。是的,谈到 Lisp 开发就不能不谈到 Emacs + SLIME 这套革命性的开发环境,没有 SLIME 的 Lisp,就像没有武器的战士一样。不同于 C++ 的先构建再运行的开发模型,Lisp 的开发模型是交互式的。你写了一个 defun 一个 defstruct ,不需要去 main 函数中写一段测试代码和 print 语句,然后编译运行看看结果是否符合预期;在 SLIME + Lisp 的开发环境中,写了一个 defunC-c C-c 即可编译完成, C-x C-e 即可执行当前的一个表达式,快速的反馈和修改能够最大程度上保证你思维的连续性。关于这点可以参考我写的走进 Lisp 的世界——兼谈 Emacs 下 Lisp的开发环境(上)
  • 强烈的数学味,更高层次的抽象,专注于 what 而不是 how。
    • Paul GrahamThe Roots of Lisp 中写到 "It's worth understanding what McCarthy discovered, not just as a landmark in the history of computers, but as a model for what programming is tending to become in our own time. It seems to me that there have been two really clean, consistent models of programming so far: the C model and the Lisp model. These two seem points of high ground, with swampy lowlands between them. As computers have grown more powerful, the new languages being developed have been moving steadily toward the Lisp model. A popular recipe for new programming languages in the past 20 years has been to take the C model of computing and add to it, piecemeal, parts taken from the Lisp model, like runtime typing and garbage collection."
    • 按照我的理解,我们可以对中文“计算机”这个词语做一次咬文嚼字的分拆,Lisp 代表着“计算”,而 C 语言则代表“机”。
    • Lisp 具有强烈的数学色彩,Lisp 程序中大量使用递归,深刻理解递归几乎是 Lisp 程序员的必备生存技能20。而 C 语言则终点关注底层机器模型, short / int / long / long long ,不同数据类型的区分,榨干机器的内存空间;大量使用指针,将完整的冯诺依曼机器模型暴露给程序员,榨干机器的整体性能。
    • 在 Lisp 中更加强调 what you want,而 C 中则更加倾向于给出长长的算法步骤和状态变换指令,专注于 how to get it。
      • 比如求一个 list 的长度,在 Lisp 中,其核心代码就是 (+ 1 (lenght (cdr lst)));而在 C 中,恐怕要设置 int i = 0 和各种计数器了21
  • 强类型的动态语言,一致的语法规则,关注实现域而非问题域,摒弃编程中的心智包袱。
    • C++ 是一门有心智包袱的语言22,在 C++ 编程中,我们常常要考虑诸如是传值还是传引用、要不要进行运算符重载、深拷贝还是浅拷贝、堆内存还是栈内存等等这些实现域而非问题域的语言细节问题。我不是说关注实现域这点不好,只是不能太过,而 C++ 的讨厌之处就在于,为了所谓一点点的性能提升,经常性地将苦命的码农们从问题域拉回实现域。
  • 万能的 List,快速的原型构建能力。
    • 如何用可递归的 List 来一体化的表达常见的数据结构,这个问题比较深刻,后续我会再写一篇文章深入探讨下。

本节的最后,还是给出完整的 Lisp 程序,核心代码只有 70 行左右,大概是 C++ 的三分之一左右。主题算法和代码来自于《ANSI Common Lisp》3.15 节。顺带广告,《ANSI Common Lisp》是非常不错的 Common Lisp 书籍(用来入门的话还是比较难啃的),300 页不到的篇幅里基本上覆盖了 Common Lisp 大部分的语言特性,并且有很多极具实用价值的小程序(最短路、行程压缩编码、二叉树、二分搜索、光线跟踪算法等等)。我认为此书之于 Lisp,相当于 K&R C 之于 C。

(defun point2index (x y n)
  "convert a coordinate point to an index"
  (+ (* x n) y))

(defun index2chess (index n)
  "convert an index back to a coordinate point"
  (floor index n))

(defun build-graph (n)
  "build a undirected unweighted graph according to the chess rules about
knight"
  ;; use lisp array to keep the vertex map
  (let ((vm (make-array (* n n) :initial-element nil)))
    ;;; define some auxiliary function
    (defun is-valid (x y)
      "whether or not the point is valid in the chess board"
      (and (>= x 0) (< x n)
           (>= y 0) (< y n)))
    (defun all-adj-points (x y)
      "build the adjacency list for point (x, y)"
      (let ((adj-list))
        ;; return every possible next knight position as a list
        (dolist (next-step
                  '((2 . -1) (2 . 1)
                    (-2 . -1) (-2 . 1)
                    (1 . -2) (1 . 2)
                    (-1 . -2) (-1 . 2)))
          (let ((nx (+ x (car next-step)))
                (ny (+ y (cdr next-step))))
            (if (is-valid nx ny)
                ;; build the adjacency list
                (push (point2index nx ny n) adj-list))))
        adj-list))
    (dotimes (i n)
      (dotimes (j n)
        (setf (aref vm (point2index i j n)) (all-adj-points i j))))
   vm))

(defun shortest-path (start end graph)
  "one-source unweighted shortest-path algorithm using bfs method"
  (bfs end (list (list start)) graph))

(defun bfs (end queue graph)
  "the internal bfs routine to find shortest path"
  (if (null queue)
      nil
      (let* ((path (car queue))
             (node (car path)))
        (if (eql node end)
            (reverse path)
            (bfs end
                 ;; pop the queue and push some new path into the queue
                 (append (cdr queue)
                         (new-paths path node graph))
                 graph)))))

(defun new-paths (path node graph)
  "return the new-paths according to the node's adj list"
  (mapcar #'(lambda (n)
              (cons n path))
          (cdr (aref graph node))))

(defun solve-knight (n x y)
  "the main function to solve knight problem"
  (let ((path (shortest-path (point2index (- x 1) (- y 1) n)
                             (point2index (- n 1) (- n 1) n)
                             (build-graph n))))
    ;; print the start point first
    (multiple-value-bind (x1 y1)
        (index2point (car path) n)
      (format t "(~A, ~A)" (+ x1 1) (+ y1 1)))
    ;; print the path
    (mapcar #'(lambda (obj)
                (multiple-value-bind (px py)
                    (index2point obj n)
                  (format t " -> (~A, ~A)"
                          (+ px 1)
                          (+ py 1))))
     (cdr path))
    ;; return the path
    path))

;;; some test
;; CL-USER> (SOLVE-KNIGHT 6 1 1)
;; (1, 1) -> (3, 2) -> (4, 4) -> (5, 6) -> (6, 4) -> (4, 5) -> (6, 6)
;; (0 13 21 29 33 22 35)
;; CL-USER> (SOLVE-KNIGHT 8 1 1)
;; (1, 1) -> (3, 2) -> (4, 4) -> (5, 6) -> (6, 8) -> (7, 6) -> (8, 8)
;; (0 17 27 37 47 53 63)
;; CL-USER> (SOLVE-KNIGHT 8 2 1)
;; (2, 1) -> (3, 3) -> (4, 5) -> (5, 7) -> (7, 6) -> (8, 8)
;; (8 18 28 38 53 63)

Python

坦白地说,我对 Python 的了解远不如 C/C++,甚至不如 Lisp,尽管我也用 Python 写过一些不大不小的原型程序,但是这些程序都没有触及到 Python 的语言核心。前两天写这个 knight rush 的程序,还要去翻书,熟悉下 Python OOP 编程的一些知识。我以为,Python 是一门实用主义至上的语言,在保证实用主义的前提下,Python 从诸多语言中吸收了很多特性,并一一做了精简(Python的 OOP 甚至没有 private,而 Python lambda 对比 Lisp 算很一般),再加上简单至上的文化和缩进式的代码风格,构成了当今 Python 语言的主要面貌。

即便如此,我认为 Python 还是值得学习的。它既不像 C/C++ 那样令人紧张、也不像 Lisp 那样阳春白雪,对比 Shell Script,Python 有自己的内建数据结构,能够在很大程度上替换 Shell Script。其实和 Python 同级的语言还是有很多的,比如 Perl,Ruby。Ruby 我不了解,但是我对 Perl/PHP/Shell 这类遍布 '$' 符号的语言一向没什么好感,因为这类语言的可读性一般都很差。

其实关于 Python 本身,我已经没有太多想法可写,可能一方面我对 Python 的了解实在算不上深入,另一方面,Python 本身也是不希望它的使用者过多关注于语言本身吧。对于 Python,简单了解后拿过来直接用就好了,什么代码风格、缩进啊,那都是过去时的事情了。

作为对比,还是贴出 Python 版的 knight rush 程序:

#!/usr/bin/env python2

import sys

class graph(object):
    """unweighted directed graph
    """

    def __init__(self):
        """set _vmap to and _vprev an empty python dict
        all vertex are represented by a simple index

        _vmap: {vertex x: x's adjacency list}
        _vprev: {vertex x: x's prev vertex computed by bfs routine}
        """
        self._vmap = {};
        self._vprev = {};

    def add_edge(self, start, end):
        """add an edge to the graph
        """
        if self._vmap.has_key(start):
            self._vmap[start].append(end)
        else:
            self._vmap[start] = [end]

    def bfs_shortest(self, start):
        """one-source shortest-path algorithm
        """
        queue = [start]

        self._vprev[start] = None

        while len(queue) != 0:
            v = queue[0]
            queue.pop(0)

            if self._vmap.has_key(v):
                v_adj = self._vmap[v]
            else:
                continue

            for nextv in v_adj:
                if self._vprev.has_key(nextv):# and self._vprev[nextv] is not None:
                    # nextv has already found its parent""
                    continue
                else:
                    queue.append(nextv)
                    self._vprev[nextv] = v

    def get_path(self, end):
        """return the shortest path as a python list
        """
        v = end;
        path = []
        while self._vprev.has_key(v) and self._vprev[v] is not None:
            path.insert(0, v)
            v = self._vprev[v]

        if self._vprev.has_key(v):
            path.insert(0, v)   # insert the start point to the path
        else:
            print "destination %d is not exist or unreachable" % v

        return path

class chessboard(object):
    """a chessboard of size n*n class
    """

    def __init__(self, n):
        """build the internal graph representation of the chessboard

        Arguments:
        - `n`: size of the chessboard
        """
        self._size = n
        self._board = graph()

        next_point = ((2, 1), (2, -1), \
                      (1, 2), (1, -2), \
                      (-2, 1), (-2, -1), \
                      (-1, 2), (-1, -2))

        for x in range(n):
            for y in range(n):
                start = self.point2index(x, y)
                for dx, dy in next_point:
                    nx = x + dx
                    ny = y + dy

                    if self.is_valid(nx, ny):
                        end = self.point2index(nx, ny)
                        self._board.add_edge(start, end)

    def is_valid(self, x, y):
        """whether or not point (x, y) is valid in the chessboard
        """
        return 0 <= x < self._size and 0 <= y < self._size

    def point2index(self, x, y):
        """convert a chessboard point to the internal graph vertex index
        """
        return x * self._size + y

    def index2point(self, p):
        """convert the internal graph vertex index back to a chessboard point
        """
        return (p / self._size, p % self._size)

    def solve_knight(self, x, y):
        """just solve it
        """
        start = self.point2index(x, y)
        end = self.point2index(self._size - 1, self._size - 1)
        self._board.bfs_shortest(start)
        path = [self.index2point(x) for x in self._board.get_path(end)]
        return [(x + 1, y + 1) for x, y in path]

def main():
    """main routine
    """
    # g = graph()
    # g.add_edge(1, 2)
    # g.add_edge(1, 3)
    # g.add_edge(2, 3)
    # g.add_edge(3, 4)
    # g.bfs_shortest(1)
    # print g.get_path(4)

    if len(sys.argv) != 4:
        print """Wrong arguments! Usage: ./knight.py N x y
        """
        return -1

    N = int(sys.argv[1])
    x = int(sys.argv[2])
    y = int(sys.argv[3])

    chess = chessboard(N)

    print chess.solve_knight(x - 1, y - 1)

    return 0

if __name__ == "__main__":
    main()


# some test data
# $ ./knight.py 6 2 2
# [(2, 2), (4, 3), (6, 4), (4, 5), (6, 6)]
# $ ./knight.py 6 2 2
# [(2, 2), (4, 3), (6, 4), (4, 5), (6, 6)]
# $ ./knight.py 4 2 2
# [(2, 2), (4, 3), (2, 4), (3, 2), (4, 4)]
# $ ./knight.py 4 1 1
# [(1, 1), (3, 2), (4, 4)]
# $ ./knight.py 4 2 3
# [(2, 3), (4, 4)]
# $ ./knight.py 20 2 3
# [(2, 3), (4, 4), (6, 5), (8, 6), (10, 7), (12, 8), (14, 9), (16, 10), (18, 11), (20, 12), (19, 14), (20, 16), (19, 18), (20, 20)]
# $

总结

本文的初衷只是想针对此次面试做一个小的总结,但是写到一半发现面试题本身可写的内容不多,于是我就顺便写写我个人对 C++/Lisp/Python 的一些思考,而题目本身就“很悲剧地”成了本文的一个引子。写作终究不是一件容易的事情,将自己心中的想法转化成纸上清晰易懂的文字是一件耗时耗力的体力脑力并重的工作。整篇文章的写作大概耗时 12 个小时,但是写作的过程中也让我梳理了下自己的知识体系,如未鹏所言,“书写是为了更好的思考 ”。

罗嗦了这么多,其核心观点只有一个,那就是“ 要学会跳出语言的框架去学习语言 ”。站得高才能看得远, 只有跳出语言的框架,才能挣断语言给你的思维所上的枷锁,超越语言本身 ,看到更广阔的图景23


  1. 由于面试前后并没有保密协议,加上本文内容主要是以我个人的一些技术思考为主,因此题目内容本引述邮件。如果违反相关招聘规定,请不吝告知,谢谢。

  2. 按照 ACM 的标准,我这样的编码速度估计是死定了。

  3. 其实我也不会,相信我,我只是在吹牛而已^_^ 。

  4. 四个程序员的一天 ,一篇非常生动的高阶函数科普小品文。

  5. 关于 lambda 函数的递归问题涉及到非常深刻的计算理论问题,其入门文章可以参考未鹏写的 康托尔、哥德尔、图灵——永恒的金色对角线 ,围绕此话题有一本奇书 《哥德尔·艾舍尔·巴赫——集异璧之大成 》,曾长期绝版,最近当当有售,大家抓紧机会。

  6. 没错,STL 和 OO 的数据封装思想几乎是背道而驰的。

  7. Python 中有 Lisp Comprehension 和类似于 Lisp 的 map / reduce / filter 套装。

  8. boost function 提供了更好的 function object 支持。

  9. 刘未鹏:泛型编程:源起、实现与意义

  10. 关于 C++ 语言特性的书籍简直可以用浩如烟海来形容,参看这里这里

  11. 关于这个 IfThenElse 模板,可以参考《C++ Templates》第 15 章的讲解与实现。

  12. 前文提到,C++ 模板代码是由 C++ 编译器在编译期“解释执行”的,其冗长的编译时间、 巨大的编译资源以及内存资源需求,使得最新的 GCC/Clang 系列编译器对模板的递归层次支持也仅有几千层而已。事实上编译时间的冗长也一直是 C++ 模板被人诟病的地方之一。

  13. 看看《C++ Templates》Part 2 吧,绝对是顶级脑细胞杀手。

  14. 即便是 int x = 3 这样简单的一条赋值语句也不是你想象中的那么简单,看看 《SICP》第三章吧。能够对变量直接赋值是不同编程范式的一个主要区别,而这又从一个很重要的角度上决定了并行计算的本质困难性。

  15. 怎么学?学习 C++:实践者的方法(Beta1)

  16. 就好比我们喜欢十进制而非二进制,完全是因为上帝赐予了我们十根手指。

  17. 你也可以说,指针是 C 程序员一切欢乐与痛苦的来源。其实我想说的是,每种编程语言的核心关注点不同,在此至上,语言本身会围绕着这个核心点发展出自己的一套设计哲学,然后根据这套哲学来指导语言本身的设计和发展。

  18. "Data is just dumb code, and code is just smart data",关于 "code is data" 的话题涉及到计算机科学里面很多深刻的论题。比如说 C 程序中的 .text 段和 .data 段,冯诺依曼体系结构哈佛体系结构,编译器代码生成等等。Code is data,and it always has been

  19. 学习 Lisp 带给你的一个思想革新就是,一个对象和这个对象的值是完全不同的东西 (eval (quote x)) 。这是个很重要的概念,但这个概念在其余语言中往往是混为一体的,至少在语法上是这样的。最简单的例子,比如 C 中的语句: x = x ,等式的右边是 x 的值而不是 x 本身,等式的左边是 x 本身而不是 x 的值,理解了这点,你就能理解 C++ 中左值和右值的概念区别。如果有机会,我会专门写篇文章,探讨下这个主题。

  20. Lisp 编程中的递归主要是数学归纳法的一种程式化转换,《Common Lisp: A Gentle Introduction to Symbolic Computation》8.11 节里面详细介绍了 Lisp 中递归的几种模式,清晰易懂,强烈推荐。

  21. 这么简单的例子也许并不足以体现出 Lisp 和 C 这两种语言不同思维模式的区别,事实上如果读者不去稍微深入地学习下 Lisp 的话,是很难体会到这种思维转变的。

  22. 具体可以参考孟岩先生的两篇文章,用 C 设计,用 C++ 编码Linux之父话糙理不糙

  23. 最后一个脚注(貌似我最近写文章脚注用得越来越多了,不知道这算是旁征博引还是逻辑不清):侃侃那些美丽的编程语言,^_^ 。