Lisp - 行者无疆 始于足下 - 行走,思考,在路上

Knight Rush——关于编程语言学习的一些思考

1 引子的引子

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

2 引子

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

在一个国际象棋棋盘上(N*N),有一个棋子"马(Knight)",处在任意位置(x, y);马的走法是日字型,即其可以在一个方向上前进或后退两格,在另一方向上前进或后退一格。请编程找出马如何从位置(x, y)走到棋盘右下角(N, N)的位置的步骤。例如:假设棋盘大小为3*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*N)的棋盘上N*N个point顺次编号为0, 1, … N*N-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这种工匠手艺活,眼高手低是最要不得的。

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

3 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++伪代码:
for (vector<int>::iterator itr = v.begin(); itr != v.end(); ++itr)
{
    do_something(*itr)
}
    • 其高阶抽象代码应该是:
for_each(v, do_something)
    • 稍微了解一点Lisp的读者都能想到如下的等价伪代码7
(mapcar #'do_something v)
    • 每次你写"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;
}

4 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就是(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的开发环境中,写了一个defun,C-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)

5 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)]
# $ 

6 总结

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

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

Footnotes:

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

 

Learning SICP: (sqrt 2.0)

接触计算机也算四年有余,对自己的所学所知却毫无信心。回头看了看以前写过的很多文章,诸如配置IDE环境啦、配置Linux操作系统啦、零零散散的小程序啦,等等诸如此类,不成体系,都是在小打小闹。因此我决定,在可以预见的几年内,一方面做好百度份内的工作,赚一点养家糊口的钱;另一方面也要从基础做起,一点一点地夯实自己的技术实力。技术是基础,无论是以后创业,还是继续在程序员的行业里面模爬滚打,扎实的技术都是一个必备的条件。Google的创始人曾经说过,要成为一个企业家,需要“Be an expert in all aspects”,而一个公司赖以生存的基础,除了良好的营销、管理,最最基本的还是需要有能拿的出手的产品,而产品的基础就是技术。所以我从来不赞成“码农没有前途”等等诸如此类的说法,这样说的人,多半自己不是一个合格的码农,又或是对IT互联网本身就没有深刻的理解,信口雌黄罢了。

人类生存于世的一大乐趣就是自己制造工具,能否制造工具也是人类之所以成为万物之灵的根本。而学计算机的一大好处就是可以方便地、随心所欲地制造自己想要的工具,没有想不到,只有做不到,工具的级别取决于程序员的技术能力。譬如Fabrice Bellard就用JavaScript写了一个PC模拟器,可以在浏览器里面跑Linux,而此君的其他作品,诸如ffmpeg、qemu、tinycc,在开源社区几乎是无人不知无人不晓。

学计算机的一大好处就是经典教材诸如SICPCSAPPTAOCP、龙书、虎书等等数不胜数,思忖再三,决定还是以MIT经典的那本SICP开始。关于SICP的具体内容我不再多言,Wikipedia一查便知。让我沉思的两点是,这本书是MIT大一新生学习计算机的第一门课,也就是计算机的导论课程,而这样一门大一新生的导论课程,在两百多页的教材中,确涉及到了图灵机理论、递归算法、lambda算子等等诸多关于编程本质的知识,不得不感叹MIT课程的高质量,同样作为国内高校翘楚的浙江大学,大一新生恐怕还都在背诵C语言各种符号的优先级,还在古老的Turbo C 2.0上写着古老的graphics.h程序呢;第二,这本书长盛不衰几十年,被数百所大学选为计算机系的教材,并且对计算机教育产生了深远的影响,国内有那本教材能够达到这样的境界?谭浩强的?严尉敏的?都不是。除了教材本身,作者的态度,还有配套的相关资料,以及由此推动的深入挖掘和研究才是最重要的。SICP的作者Gerald J. Sussman同时也是Scheme语言的发明人之一。

利用晚上和周末空余的时间,断断续续地看完了SICP第一章的大半部分,还有前面的几段讲义和视频。讲义中的有一个求方根的程序:

#!/usr/bin/guile -s
!#

(define square
  (lambda (x)
    (* x x)))

(define average 
  (lambda (x y)
    (* (+ x y) 0.5)))

(define close-enuf?
  (lambda (guess x)
    (< (abs (- (square guess) x)) 0.001)))

(define improve
  (lambda (guess x)
    (average guess (/ x guess))))

(define sqrt-loop 
  (lambda (G X)
    (if (close-enuf? G X)
      G
      (sqrt-loop (improve G X) X))))

(define sqrt
  (lambda (x)
    (sqrt-loop 1.0 x)))

;;(display (sqrt 2))
;;(newline)

短短几行,几乎涵盖了二分法的精髓。程序就是数据,函数本身可以当数据来操作,本身就蕴涵着深刻的和谐统一的数学美。

其实学习这个东西就像武侠小说里的武功,公式技巧编程语言都是花拳秀腿,对整个学科体系的理解、数学的功底才是精深的内功,是一切上乘武功的根基。由此我又想到一个人的工作的价值。私以为,一个人的价值(也可以说是薪水),是以这个人的不可替代性来衡量的。如果你现在走掉,而你的老板随随便便就能找一个人来顶替你的岗位,那么你做的工作是可替代性非常高的工作,自然薪水也不会太高;反之亦然。

ps:写博客写到一半的时候is-programmer忽然挂掉,所以出现了半截文章。看来我也该考虑考虑租一个独立的虚拟主机或者VPS了。




Host by is-Programmer.com | Power by Chito 1.3.3 beta | © 2007 LinuxGem | Design by Matthew "Agent Spork" McGee