12. Command Line Programming, Git, Project 2 Preview
约 2129 字大约 7 分钟
2025-05-10
一点点进制知识
二进制、十进制、十六进制
命令行编译
javac 编译器首先把 java 文件编译成 class 字节码文件
java 解释器随后执行 class 字节码。
Intellij IDEA 这个 IDE 把两个过程隐藏成了一步。
main 函数的秘密
public class ArgsDemo {
/** Prints out the 0th command line argument. */
public static void main(String[] args) {
System.out.println(args[0]);
}
}
我们会发现 main 函数的参数列表总是有个 String[] args
,这到底是干嘛的。执行上面的程序你就知道了。
$ java ArgsDemo hello some args
hello
懂了吗?这个参数列表的传递靠的是命令行。
Git
我们经常用 Git 作为版本管理工具,接下来要补充一些 git 的知识。
Git 是用 C 语言编写的。我们在命令行里执行 git
命令就可以运行 git 程序,而不是 java git
,这是因为 C 语言编译出的二进制文件可以直接由计算机执行,而不像 java 还需要一个 Java 解释器。
Git 程序运行传递参数是通过 C 语言中的 const char **argv
实现的,这跟 Java 的 String[] args
是一样的。
Git 的 main 函数传递的 argv
指针随后被调用到 cmd_main
函数了。
Git 是个非常复杂的程序,还用到了很多我们还没有学到的知识。比如说 maps、hashing、I/O 和 graphs。
对了,现在我们给大家讲 Git 是因为,proj 2 要自己实现一个 git,叫做 gitlet。
基础的 Git 功能
为什么需要版本管理
软件开发是个不断迭代的过程,版本管理主要有下面两个好处:
- 多人协作的时候,可以确保自己的代码不会干扰别人的工作。
- 单人工作的时候,可能会进行一些复杂的改动,我们需要随时退回之前的版本。
最简单的版本管理
我们平时工作最常见的版本管理就是,把源代码拷贝一份,改名为 xxx 第二版、第三版...
这样虽然很简单,但是会带来许多缺点:
- 浪费空间
- 恢复到原来的代码很复杂
- 需要经常备份
- 如果要把两个备份合并在一起很困难
因此,为了解决这些问题,出现了很多版本管理工具。
版本管理软件
近些年出现了很多版本管理软件:
- Git (2005, open source)
- Perforce (1995)
- SVN (2000, open source)
- Mercurial (2005, open source)
其中,git 是最出名的一个。
Git 是如何工作的
每次当我们 commit 改动的时候,git 把整个项目都保存到了一个隐藏起来的文件夹:.git
但是有个问题:假如每次都把项目复制一份,那么 .git
文件夹会越来越大,越来越冗余。为了解决这种冗余,我们会有很多小点子来解决它。
避免冗余
考虑下面的场景
假如说我们接下来会有三个 commits:
- V1: 创建 readme.txt。
- V2: 创建 utils/Utils.java,game/Game.java,sgame/Test.java。修改 readme。
- V3: 修改 game/Game.java,readme.txt 回退到 V1。
方法一:对整个项目都存储一次备份
commit
操作,只需要在 .git
文件夹下面创立一个子文件夹,名为 V1 等等,然后把整个项目拷贝进去就行。
checkout
操作,只需要把当前文件夹下的所有东西删除,然后把目标子文件夹下的所有文件拷贝过来就行。
但是这样的方法效率很低。
方法二:子文件夹只存储修改了的文件
创建多个版本子文件夹,每个版本文件夹只存储修改的文件。
- 这个方法会更加高效。
- 但是
checkout
操作会很复杂。为了切换到某个版本,我们需要在各个子文件夹中把文件拷贝过来,也很麻烦。
举个例子:
.git/v1/Hello.java
.git/v2/Hello.java
.git/v2/Friend.java
.git/v3/Friend.java
.git/v3/Egg.java
.git/v4/Friend.java
.git/v5/Hello.java
上面是 version 1 到 version 5 的记录。如果我们从 version 5 切换到 version 4,我们会需要哪些文件?
我们需要 v2 的 Hello.java
,v4 的 Friend.java
以及 v3 的 Egg.java
,这里可以用以下方法来表示:
V4: Hello.java → v2, Friend.java → v4, Egg.java → v3
这和 map 或者说是字典这样的数据结构很类似。
方法三:在方法二上添加数据结构
基于上面的数据结构,我们改进一下表示方法:
.git/v1/Hello.java
.git/v2/Hello.java
.git/v2/Friend.java
.git/v3/Friend.java
.git/v3/Egg.java
.git/v4/Friend.java
.git/v5/Hello.java
对于上面的版本管理文件夹,我们可以换成一下方法表示:
V1: Hello.java → v1
V2: Hello.java → v2, Friend.java → v2
V3: Hello.java → v2, Friend.java → v2, Egg.java → v3
V4: Hello.java → v2, Friend.java → v4, Egg.java → v3
V5: Hello.java → v5, Friend.java → v4, Egg.java → v3
下面做一个练习题:
.git/v1/X.java
.git/v1/Y.java
.git/v2/Y.java
.git/v3/Z.java
.git/v4/X.java
.git/v4/A.java
.git/v5/X.java
.git/v5/Y.java
如果我们切换到 v4,我们需要拷贝哪些文件?
V4: X.java → v4, Y.java → v2, Z.java → v3, A.java → v4
这就很明确了。
采用字典这样管理版本的方法,还有一个好处是,假如说上面的 v5 的 X.java 跟 v1 一样,X.java 直接就指向 v1 了。
用 hashing 消除冗余
方法四:把时间作为版本号
现在来个新的例子。我们把版本号改成时间日期试一试:
.git/02_16_2021_03_29_45/X.java
.git/02_16_2021_03_29_45/Y.java
.git/02_16_2021_11_29_45/Y.java
.git/02_16_2021_11_29_45/Z.java
.git/02_16_2021_13_29_45/X.java
表示方法如下:
V02_16_2021_03_29_45:
- X.java → 02_16_2021_03_29_45
- Y.java → 02_16_2021_03_29_45
V02_16_2021_11_29_45:
- X.java → 02_16_2021_03_29_45
- Y.java → 02_16_2021_11_29_45
- Z.java → 02_16_2021_11_29_45
V02_16_2021_13_29_45:
- X.java → 02_16_2021_13_29_45
- Y.java → 02_16_2021_11_29_45
- Z.java → 02_16_2021_11_29_45
但是这样有个问题:假如有两个程序员都在修改,一个修改并提交了 Horse.java
,另一个修改并提交了 Fish.java
,这种情况,这个方法就会失效。
方法五:把哈希值作为版本号
Git 实际上是用 git-SHA1 hash
函数处理文件,并将哈希值作为版本号的。哈希的特点是,两个一模一样的文件,其哈希值是一样的。
Git-SHA1 hash
实际上是由 文件大小
+ 0
+ 文件的SHA1 哈希值
组成的,一共 160 位。
假如说我们让 git 存储 Hello World 程序:
- 首先,git 计算 git-SHA1 哈希值:HelloWorld.java → 66ccdc645c9d156d5c796dbe6ed768430c1562a2
- Git 会创建一个名为 .git/objects/66 的文件夹,66 是 git-SHA1 哈希的前两个字符。
- Git 将内容存储在名为 ccdc645c9d156d5c796dbe6ed768430c1562a2 的文件中。文件以压缩格式 (zlib) 存储以节省空间。
总结
最后让我们比较一下之前讲的方法:
方法序号 | 方法特点 | 缺点 |
---|---|---|
1,2,3 | 用 Version 1 这样的序号提交 | 没有中心服务器,无法确定版本先后顺序 |
4 | 用时间日期提交 | 两个人同时提交的时候会出问题 |
5 | 用哈希值提交 | 哈希值可能会碰撞,但是概率极小 |
哈希还有一个好处:安全。假如黑客攻击了代码仓库,改动了一个代码,那么其哈希值肯定会改变,git 可以直接检测出来;还有是下载文件的时候可以通过哈希码检验下载的完整性。
可序列化和存储数据结构
Git commit
每一个 commit 会包括以下信息:
- 作者
- 时间日期
- 提交信息(commit message)
- 所有文件及其版本的列表(文件版本是文件的 git-SHA 1 哈希值)
- 父级的 commit ID(commit ID 是整个 commit 的哈希值)
我们用 Java 代码实现一下一个 commit 类:
public class Commit {
public String author; // 作者
public String date; // 提交时间日期
public String commitMessage; // commit message
public String parentID; // 父级的commit ID
// 可以理解为父级的 Commit 对象整个都拿去哈希
...
}
还有一件事:这个 commit 对象要作为数据存储下来。Java 内置了一个 Serializable 接口,专门用于存储对象,对象只需要 implements 这个 Serializable 类就可以被存储,而且不需要实现任何方法。然后我们写一个 Utils 类,来负责从文件中读写对象。
Branching
Merging
版本管理系统还有一个特性:创建分支。
我们在做项目的时候,可能不会太相信现有的 master 分支很好,打算自己在 master 的基础上写一个新的项目,这个时候就可以自己新建一个分支(branch)。
checkout
命令不仅可以在不同的分支上切换,还可以通过选项 -b
,在某个分支的基础上新建分支。
merge
命令可以把 master 分支与指定的分支进行合并。我们之后自己写 gitlet
程序的时候 merge 命令会是个大难题。而且 merge 之后,整个版本管理不再是链表,而是图了。