Git's functional nature (with an encoding in OCaml)

code17's picture

昨天研究了一下 git 的设计,一个发现是 git 的强大很大程度上源自于它的函数性设计。

Git 并不是一个程序,而是一系列工具围绕在一个核心表示的周围,而这个核心是 functional 的。在Git 的核心表示中,实体(objects),比如 blob, tree 和 commit, tag,全是 persistent,也就是说一旦记录,永不改变。

其中,最根本的概念是 commit。一次 commit,记录了当下文件目录结构的全部状态。其主要信息,由 blob(文件内容) 和 tree(目录结构) 的某种编码构成,为了简单我们可以把它想象成 FP 里常见的"树":

type tree =
  | Blob of blob
  | Tree of string * tree list

既然这些全都是 persistent 的,那么 commits 如何 evolving 呢?

很简单,当新的commit 产生,git 所做的事实际上是产生一棵全新的树记录当前结构。但对于那些没有改变的部分(与它所基于的上一个commit的状态相比),这棵新的树将直接使用那些旧的树里的部分。下面这个函数,将一棵树"tree"里的子树"old_tree"替换为"new_tree"生成一棵新树,但 "tree" 并没有改变,和生成的新树同样 accessible,而且共享尽可能多的数据。

let rec substitute old_tree new_tree tree =
  if tree = old_tree then new_tree
  else match tree with
  | Tree (s, tl) ->
      let tl' = List.map (substitute old_tree new_tree) tl in
      if List.for_all2 (==) tl tl' then tree else Tree (s, tl')
  | _ -> tree

(注意 OCaml 代码里 structral equivalence 和 physical equivalence 的异同)。 这就是 FP 里的 functional updating 的概念。这里,当旧的树的某一部分被update,所产生的新树与旧树的唯一区别就是从该部分开始从下往上一直到树根这一路径上的所有节点,其他部分全都共享的,而它们确实表现为完全独立的两棵树。一个极端的例子,如果我们拷贝一棵树,所增加的无非是一个指向原树的pointer而已 !——哈哈,你可能已经开始联想到 Git 里的 branch 为什么是如此轻松和廉价了,同一个道理!!!

正因为 Git 里这些实体都是 persistent 的,这种共享没有任何问题。这意味着当新的 commit 产生,旧的 commit 依然存在且没有任何变化,它们只是不同的两棵树的顶点,他们的某些部分可能 point 向共同的数据,而这些数据既然是 persistent 或者说 immutable 的,自然没有什么改变其中一个会影响另一个的说法。所以你永远都没办法去"无意中"破坏已有的历史(sort of);所以你可以随时回到某一个时间某一个位置!我们看到 git 上的附加工具越来越多,是由不同的人写的,但关键是它们彼此之间不会互相影响,一般不会说 “你用 git-xxx 的时候要注意,如果用了它后立即用 git-yyy 可能会有问题,应该先执行一次 git-zzz”,为什么?哈哈,你可能已经联想到 states, side-effects 了,persistence 也意味着 purely functional!

让我们回到 Git 的概念上。一个新的 commit 除了记录数据本身,还记录和自己的 parents commit 的关系,Again,这只不过是一个 persistent 的 pointer 而已,这就构成了 history。一般的 commit 只有一个 parent,但也可以有多个,OK,你猜对了,是 merge 的时候。这样,一个 git project 的历史无非就是一堆 commits 连成的一个 DAG (有向无环图)。

那么什么是tag呢?tag也是persistent的,它其实就是一个pointer with readable name,指向某个已有的 commit, nothing else.

最后,什么是branch? branch 是唯一的 non-persistent 元素,简单讲,tag 是 persistently 地指向 某个 commit,而branch 就是个 moving tag,non-persistently 指向那些 commits,一旦当前 branch 有新的 commit,我们就改变当前 branch 的指向到那个新的 commit。而创建新的 branch,无非就是增加一个可以独自 evolving 的 moving tag。另有一个叫做 HEAD 的独特的 pointer,指向当下活动的 branch,而 branch switch 无非是 load 另一个 branch 所指向的 commit,然后改变 HEAD 指向这个 branch 罢了。

最后,你可以看到 git 有一个 gc (垃圾收集) 指令,这更加验证了 git 的 functional nature。当某些数据是 unreachable 的, 就应该应该被或早或晚地 GC——比如当我们 drop 了一个 branch,那么那些 唯一 属于这个 branch,比如在这个 branch 的历史上而不再其他任何 branch/tag 的历史上的那些 commit, tree, blob etc。这与语言里的 GC 概念非常相似,当一个数据不再被其他任何数据 reference,就意味着我们不能根据任何 name/value 来 access 它,那么留之何用? 大概 git 出于效率的考虑,在 drop branch 的时候可能只是删除了部分的 unreachable data,而其它的部分,可以在今后手动执行 gc 命令来手动清除。在我们下面的 encoding 里则没有这个问题,因为OCaml有自动的GC

最后给一个简单的 conceptual encoding,用 OCaml 写的:

(* Since it's just a demonstration of concepts,  we deliberately omit the
    system programming part such as file system interfaces and the
    monitoring/checking of files/directories changes since last commit.
    We only deal with a very small fraction of the basic operations of git,
    though a lot more is possible in this way.
*)

(* blob is the git's jargon for content of a file, it should be encoded with
     more information such as name and verification bits etc. Here we simplify
     it as just a string. The same for the tree definition below.
*)

type blob = string

(* tree is a directory structure with files and subdirs inside *)
type tree =
  | Blob of blob
  | Tree of string * tree list

type commit = { parents: commit list; self: tree; commit_log: string }

type tag = { tag_name: string; point_to: commit; tag_log: string }

type branch = { branch_name: string; mutable last_commit: commit }

type git_repo =
    { mutable head: string;
      mutable branches: branch list;
      mutable tags: tag list }

(* This is a global state, hence not functional. But in practice, git use
    implicitly a git repo located in current directory or its up layers , we
    simulate it here.
*)

let local_repo = { head= ""; branches = []; tags = [] }

(* The state of the current working copy. *)
let local_state = ref (Tree (".", []))

(* Since we omit the system programming part, We don't deal with git's
   "index" and "add" concepts either, otherwise we should have a "local_index"
   variable.
*)

(* Again, this is dangerous, since "master" branch is not yet committed, but
   it simulates git's behavior. *)

let init () = local_repo.head <- "master"

(* helper function *)
let current_branch() =
  List.find (fun r -> r.branch_name = local_repo.head) local_repo.branches

(* This is the key point, in substitute, the generated tree physically shares
    as much data as possible with the input tree.
*)

let rec substitute old_tree new_tree tree =
  if tree = old_tree then new_tree
  else match tree with
  | Tree (s, tl) ->
      let tl' = List.map (substitute old_tree new_tree) tl in
      if List.for_all2 (==) tl tl' then tree else Tree (s, tl')
  | _ -> tree

(* Due to the lack of system programming part, We'll simply say please
    change some [old_tree] part with the [new_tree] part on commit.
    The real arguments can be deduced by comparing the files/directories
    against last commit.
 
    For the sake of code clearness,  we only deal with the cases of changing
    one  sub-tree at a time. Changing multiple sub-trees can be implemented
    with a List.fold of the substitute function.
*)

let commit ?old_tree ?(log = "") new_tree =
  if local_repo.branches = [] then begin
    (* First commit, no branches, no tags yet *)
    assert (local_repo.head = "master");
    assert (local_repo.tags = []);
    let new_commit = { parents = []; self = new_tree; commit_log = log} in
    let new_branch = { branch_name = "master"; last_commit = new_commit } in
    local_repo.branches <- [new_branch]
  end else
    let cur_branch = current_branch() in
    let last = cur_branch.last_commit in
    let new_commit =
      { parents = [last] ;
        self = (match old_tree with
                | None -> new_tree
                | Some tree -> substitute tree new_tree last.self);
        commit_log = log } in
    cur_branch.last_commit <- new_commit

let tag ?(log="") name =
  assert (List.for_all (fun t -> t.tag_name <> name) local_repo.tags);
  let cur_branch = current_branch() in
  let last = cur_branch.last_commit in
  let new_tag = { tag_name = name; point_to = last; tag_log = log } in
  local_repo.tags <- new_tag :: local_repo.tags

let tag_del name =
  local_repo.tags <- List.filter (fun t -> t.tag_name <> name) local_repo.tags

(* Beware, git's actual semantics is a bit complex here. It says you're not on
    any branch when checking out a tag. Anyway, this is simplified.
*)

let tag_checkout name =
  let tag = List.find (fun t -> t.tag_name = name) local_repo.tags in
  local_state := tag.point_to.self;
  local_repo.head <- ""

let branch name =
  assert (List.for_all (fun b -> b.branch_name <> name) local_repo.branches);
  let cur_branch = current_branch() in
  let last = cur_branch.last_commit in
  let new_branch = { branch_name = name; last_commit = last } in
  local_repo.branches <- new_branch :: local_repo.branches

(* When a branch is deleted, the data that exclusively belongs to this branch
    (i.e. not reachable through/shared with other branches/tags) is going to be
    GCed by manually tigger git-gc, and here by OCaml's automatic GC.
*)

let branch_del name =
  assert (local_repo.head <> name);
  local_repo.branches <-
    List.filter (fun b -> b.branch_name <> name) local_repo.branches

let branch_checkout name =
  if local_repo.head <> name then
    let branch = List.find (fun b -> b.branch_name = name) local_repo.branches in
    local_state := branch.last_commit.self;
    local_repo.head <- branch.branch_name

chylli's picture

sounds good, but...

其实我关心的是,在git里cp一个文件时,并没有相应于 svn里的cp命令. 而实际上,我觉得这样的操作在开发过程中还是相当多的,比如临时增加一个html 的语言目录,有可能将en template cp为 cn template. 这样的操作在svn里是廉价的,因为它记录了 cn为en的子孙, cn为en的祖先。但在git里并无相应的命令。看过一点资料说,git会自动检测这样的 copy。它是怎么做的呢? 如果有上万个文件,这个检测如何有效率?

另外,svn的每个branche只是一个子目录,我觉得很赞。但在git里是行不通的,比如一个目录 /a/b/c,我想另建一个c的branche,在svn里可以形成以下目录, /a/b/c,d ,而在git里则必须另建一个 /a/b/d,我觉得这一点不如svn 方便.

code17's picture

关于 git-cp 和其它

原文讲的是 Git 的 functional design,你这里讨论的是 Git 有没有某个特定的 feature 以及和其他 VCS 的比较,除非你说明这种 feature 的缺失是因为使用了 functional design 的 trade-off,否则真是有点 OT。

Anyway, 让我尝试回答你的问题:

Btw,我刚刚开始使用 Git,前几年我的 VCS 主要是 Darcs (我是说当我有的选的时候,否则其他人用什么我就配合他们 CVS/SVN/Hg etc.,参见我前面关于 Darcs 和其它 VCS 的 讨论),所以我的观点很可能是错的,大家批评指正。

这样的操作在svn里是廉价的,因为它记录了 cn为en的子孙, cn为en的祖先。但在git里并无相应的命令。看过一点资料说,git会自动检测这样的 copy。它是怎么做的呢? 如果有上万个文件,这个检测如何有效率?

让我来复述一下你的意思:你这里所说的 cp 的优点无非是两个文件可以:1) 共享数据 2) 共享历史,对吧?

  • 先说共享数据,首先要明白各种 VCS 里的文件的内部表示。传统的 CVS/SVN 基本上是 file + patch 的模式,Darcs 是全 patch 的模式,Git 是全 file 的模式——也就是说只要两个文件不同,在 Git 里本来存的就是两份,支持不支持 cp 都是一回事,省不了空间的 (git-cp = cp + git-add,如果你明白这个道理你就知道,其实连 git-mv/git-rm 这样的命令都是多余的,其实就是 mv/rm + index op)。这里我不想展开说这样的设计各有什么缺点优点,每个 VCS 自己选的,都有各自的道理,看重不同的因素,全是各种 trade-off 而已。但如果你说 Git 浪费空间的话,那么应该解释一下 Ruby on Rails 一个plain copy 是 ~9M, 加上全部的 history 的 repo 在 Git 里只是 ~13M,而加上全部 history 的 repo 在 SVN 里却是~115M;如果你觉得 Git 的基于 file 的设计不够 general 的话,那么我想 Darcs 也可以这么说 SVN 。

  • 再说共享历史,这在 Git 里是通过自动 detect 文件相似性来确定文件彼此的继承关系的(非同名的情况下),而且这种 detect 的 fuzzy 程度是可以用参数调控的。也就是说文件记录了就是简单记下了而已,本身没有什么关联信息的,只是届时看你怎么去识别罢了。记录是persistent的,你对识别不满意的话随时可以调参数甚至发明更智能的识别。至于效率,你可以看到了,这是可以自己调控的,缺省的参数就相当高效,另外 Git 的哲学是尽可能多的 commit,为任何独立的变动作 commit,这是个好习惯,也使得识别变得容易.另外如前所述。因为文件本身是不记录关联信息的,因此识别是 On-Demand 的,比如当你浏览一个gitweb powered project的时候,只有你浏览的那部分页面涉及到的文件的当前 commit 与其上一个 commit 之间的关系会被识别,当然很快。我最初也不太理解这样的设计,但在使用中我发现这并没有什么问题,绝大部分时候我们并不 care 那么 precise 的 history,大部分时候我们只是工作在上一个commit和当前的working copy 之间,最多涉及到最近几次 commit 之间的联系,而当我们 care 的时候,我们可以调整,因为记录都在那儿,是不变的。至于实现方面,很容易理解,在上述基于 file 的设计中,文件彼此独立(不像 file 与 patch 有依附关系),Git 里也不记录文件之间的关系,只记录 commit 之间的关系。当然我认为给 file 增加 meta info 也并不是什么非常困难的事情,但我想 Git 的设计是考虑过 complicity 和 efficiency 和 consistency (尤其是在 distributed 的环境下) 等方面的 trade-off,Git 的 core 可以说是极简设计。

另外,svn的每个branche只是一个子目录,我觉得很赞。但在git里是行不通的,比如一个目录 /a/b/c,我想另建一个c的branche,在svn里可以形成以下目录, /a/b/c,d ,而在git里则必须另建一个 /a/b/d,我觉得这一点不如svn 方便.

我不明白这里你想要什么效果?在我看来你直接开一个branch,然后随便改成你要的效果不就行了,比如 cp -r /a/b/c /a/b/d; git-add /a/b/d。我不知道单独为子目录开 branch 有什么好处,除了增加 confusion,开一个branch无非是增加一个指针。

OK,我想你考虑的是 synchronization 的问题,希望两个 branch 共享一个 common part。那样的话,别忘了 Git 不是 centralized 的。两个 branch 以及它们的 common part 可能分别有很多 physically distributed 的 clone 在很多不同的道路上 evolving,这样 common part 也就没有什么特殊性了,一样要手工 synchronize ,explicit 的 rebase 和 merge 显然更符合 distributed nature。Btw,别忘了 git-submodule 。

chylli's picture

多谢

抱歉,我问的问题和你的主题关系不大.
其实我并不是否认git,如上所述,我并没说git浪费空间,我只是说它是如何做的不浪费空间。如何实现的它的高效率。如你所说,保存的无非是diff,那也得有祖先成行.比如1万个文件,我复制一个,在svn里很容易理解,它是弄了一个指针,指向了原结点。在git里是如何做的呢? 1.如果是用指针,那它如何有效的检测到哪个文件是祖先呢?2. 如果检测不到,那肯定就浪费了空间。它是如何折衷的呢,是花费10分钟来查找一个文件是从哪copy的呢,还是浪费10M的空间保存一个新文件? 我只是想知道git怎么做的,并没说它做的不好 :p

第二个问题则是个人好恶问题,不讨论也罢.

code17's picture

Git 里的文件实体是 blob 而不是 file

我还没时间看这部的源码,以下仅是阅读一些文章后的大致了解和猜测:

如前所述,文件间的联系是不记录的,只有 commit 间的关系,没有文件间的关系。一个原文件和一个改动过的文件,存储的结果不是一个文件加上一个patch,而是两个完整的文件,也没有任何指针或其它的关系的记录。

但没有改动的 copy/move 是不浪费空间的,那是两个 commit 的 tree 共享同一个 blob (这也是为什么它叫blob(文件内容)而不是file的原因),不占用额外的空间。如何知道一个文件的内容是由另一个文件copy/move而来?简单,文件压缩存储的时候就要作 SHA-1 啊,而且这个信息的一部分是直接用来做文件名的,比较这个字符串就好了,这种情况下祖先关系是很显然的。

至于同时有 modify 的 copy/move,即便是分解为先 copy/move 再 modify,根据前面的原理也一样是要存两个完整文件,和两个文件被视为没有联系的情况下占用空间是一样的。这种情况下,他们之间的联系是通过 On-Demand 的 fuzzy detect 来确认的,一般是在你浏览历史的时候才需要 ,其他情况很少用到 (在大多数VCS中,对单个文件的操作大都是对 last-commit 进行的,而跨代(commits)操作,大都是以整个commit为单位的)。但是,如果把它分解为 copy/move 和 modify 两次 commit,是符合 best practice 的,因为这样在这两步中祖先关系都是明确的 ,不需要 detect。

HTH.

chylli's picture

thank

获益良多