在实践中,计算机代码不断被转换。在项目开始时,计算机代码通常采用逐渐细化的草图形式。后来,代码可以优化或更正,有时会持续很多年。
很快,程序员意识到他们不仅需要存储文件,还需要跟踪给定文件的不同版本。我们都熟悉软件通常与版本相关联的事实,这绝非偶然。有必要区分计算机代码的不同版本以便跟踪更新。
我们可能会认为,在开发了一个新版本的软件之后,以前的版本可能会被丢弃。但是,出于以下几个原因,保留每个版本的计算机代码的副本是可行的:
- 我们认为适当的代码更改可能会导致问题:我们需要能够快速返回。
- 有时同时使用不同版本的计算机代码,不可能所有用户都切换到最新版本。如果在先前版本的计算机代码中发现错误,则程序员可能需要返回并更正先前版本的计算机代码中的错误而不更改当前代码。在这种情况下,软件的发展并不是严格线性的。因此,可以发布 1.0 版,然后发布 2.0 版,然后发布 1.1 版。
- 有时能够及时回过头来研究代码的演变以了解一段代码背后的动机是很有用的。例如,可能已经添加了一段代码而没有太多注释来快速修复一个新错误。细心的程序员将能够通过返回并阅读上下文中的更改来更好地理解代码。
- 计算机代码通常由同时工作的不同程序员修改。在这样的社会背景下,能够快速确定谁做了什么改变以及什么时候改变通常是有用的。例如,如果问题是由一段代码引起的,我们可能想询问最后在该段上工作的程序员。
程序员很快意识到他们需要版本控制系统。版本控制系统提供的基本功能是回滚和所做更改的历史记录。随着时间的推移,版本控制的概念已经传播开来。甚至还有几个面向公众的变体,例如 DropBox,其中存储了各种文件,不仅是计算机代码。
软件版本控制工具的历史可以追溯到 1970 年代( Rochkind,1975 )。 1972 年,Rochkind 在贝尔实验室开发了 SCCS(源代码控制系统)。该系统使得在软件项目中创建、更新和跟踪更改成为可能。从 1970 年代末到 1980 年代,SCCS 一直是一个参考。 SCCS 的限制之一是它不允许协作工作:在给定时间只有一个人可以修改给定文件。
在 1980 年代初期,Tichy 提出了 RCS(修订控制系统),它通过仅存储文件不同版本之间的差异而不是完整文件来对 SCCS 进行创新。 RCS 比 SCCS 更快并且使用更少的磁盘空间。
在编程中,我们通常将计算机代码存储在文本文件中。文本文件最常使用 ASCII 或 Unicode(UTF-8 或 UTF-16)编码。行由标识行开头的一系列特殊字符分隔。行首经常使用两个字符:“回车”(CR)和“换行”(LF)。在 ASCII 和 UTF-8 中,这些字符分别用值为 13 的字节和值为 10 的字节表示。在 Windows 中,行开始序列由 CR 字符后跟 LF 字符组成,而在 Linux 和 macOS 中,仅使用 LF 字符。在大多数编程语言中,我们可以分别用转义序列 \r 和 \n 来表示这两个字符。因此,字符串“a”在 Linux 或 macOS 下的大多数编程语言中都有三行:“a”、“b”和“c”行。除第一行外,文本文件中的所有行都必须以行首序列开头。
当程序员编辑文本文件时,通常只更改所有行的一小部分。也可以插入或删除一些行。通过识别新行、删除行和修改行,可以方便地尽可能简洁地描述差异。
通常首先通过将文本文件分成几行来计算两个文本文件之间的差异。然后,我们将文本文件视为行列表。给定同一文件的两个版本,我们希望将第一个版本中尽可能多的行与第二个版本中的相同行关联起来。我们还假设行的顺序没有颠倒。
我们可以通过寻找最长的公共子序列来形式化这个问题。给定一个列表,一个子序列只取列表的一部分,不包括一些元素。例如,(a,b,d) 是列表 (a,b,c,d,e) 的子序列。给定两个列表,我们可以找到一个公共子序列,例如 (a,b,d) 是列表 (a,b,c,d,e) 和列表 (z,a,b,d) 的子序列。两个文本行列表之间的最长公共子序列表示在文本文件的两个版本之间未更改的行列表。使用蛮力解决这个程序可能很困难。幸运的是,我们可以通过动态规划计算最长公共子序列。事实上,我们可以进行以下观察。
- 如果我们有两个长度为 k 的最长子序列的字符串,并且我们在两个字符串的每个末尾添加相同的字符,则新字符串将具有长度为 k+1 的更长子序列。
- 如果我们有两个长度为 m 和 n 的字符串,以不同的字符结尾(例如,“abc”和“abd”),那么这两个字符串的最长子序列是两个字符串在删除最后一个字符后的最长子序列两个字符串之一。换句话说,要确定两个字符串之间最长子序列的长度,我们可以取从第一个字符串中截去一个字符后的子序列长度的最大值,同时保持第二个字符不变,以及截去一个字符后的子序列长度第二个字符串中的字符,同时保持第一个字符串不变。
这两个观察结果足以有效计算最长公共子序列的长度。从仅包含第一个字符的字符串开始并逐步添加后续字符就足够了。通过这种方式,可以计算截断字符串之间的所有最长公共子序列。然后可以反转此过程以从末尾开始构建最长的子序列。如果两个字符串以相同的字符结尾,我们知道最后一个字符将是最长子序列的一部分。否则,两个字符串中的一个会从它的最后一个字符中截断,从而使我们以最大化最长公共子序列的长度的方式进行选择。
以下函数说明了此问题的可能解决方案。给定两个字符串数组,该函数返回最长的公共子序列。如果第一个字符串的长度为m
,第二个为n
,则算法在O(m*n)
时间内运行。
函数最长子序列(文件1 ,文件2 [ ]字符串) [ ]字符串{ m , n : = len ( file1 ) , len ( file2 ) P : =使( [ ] uint , ( m + 1 ) * ( n + 1 ) ) 对于i : = 1 ;我< =米;我+ + { 对于j : = 1 ; j < = n ; j + + { 如果文件 1 [ i - 1 ] = =文件 2 [ j - 1 ] { P [我* ( n + 1 ) + j ] = 1 + P [ ( i - 1 ) * ( n + 1 ) + ( j - 1 ) ] }其他{ P [我* ( n + 1 ) + j ] =最大值( P [我* ( n + 1 ) + ( j - 1 ) ] , P [ (我- 1 ) * ( n + 1 ) + j ] ) } } } 最长: = P [ m * ( n + 1 ) + n ] 我, j : =米, n 子序列: = make ( [ ]字符串,最长) 对于k : =最长; k > 0 ; { 如果P [我* ( n + 1 ) + j ] = = P [我* ( n + 1 ) + ( j - 1 ) ] { j - - // 两个字符串以相同的字符结尾 } else if P [ i * ( n + 1 ) + j ] = = P [ ( i - 1 ) * ( n + 1 ) + j ] { 我- - } else if P [ i * ( n + 1 ) + j ] = = 1 + P [ ( i - 1 ) * ( n + 1 ) + ( j - 1 ) ] { 子序列[ k - 1 ] = file1 [ i - 1 ] k - - ;我- - ; j - - } } 返回子序列 }
一旦计算了子序列,我们就可以快速计算出两个文本文件之间差异的描述。只需在每个文本文件中逐行向前移动,一旦到达与最长子序列的元素相对应的位置就停止。与第一个文件中的子序列不对应的行被视为已删除,而与第二个文件中的子序列不对应的行被视为已添加。以下函数说明了一种可能的解决方案。
函数差异(文件1 ,文件2 [ ]字符串) [ ]字符串{ 子序列: =最长的子序列(文件1 ,文件2 ) 我, j , k : = 0 , 0 , 0 答案: = make ( [ ] string , 0 ) 对于我& lt ; len ( file1 ) & amp ; & amp ; k & lt ;长度(文件2 ) { if file2 [ k ] = =子序列[ j ] & amp ; & amp ;文件 1 [ i ] = =子序列[ j ] { 答案=追加(答案, “ ' ” +文件2 [ k ] + “ ' \n ” ) 我+ + ; j + + ; ķ + + }其他{ 如果文件1 [我] ! =子序列[ j ] { answer = append ( answer , " deleted: ' " + file1 [ i ] + " ' \n " ) 我+ + } 如果文件 2 [ k ] ! =子序列[ j ] { answer = append ( answer , " added: ' " + file2 [ k ] + " ' \n " ) ķ + + } } } 对于;我& lt ;长度(文件1 ) ;我+ + { answer = append ( answer , " deleted: ' " + file1 [ i ] + " ' \n " ) } 对于; k & lt ;长度(文件2 ) ; k + + { answer = append ( answer , " added: ' " + file2 [ k ] + " \n " ) } 返回答案 }
我们提出的用于计算最长子序列的函数使用了O(m*n)
内存元素。可以减少此函数的内存使用并简化它( Hirschberg,1975 )。在实践中还可以进行其他一些改进( Miller 和 Myers,1985 年)。然后我们可以用简洁的方式表示两个文件之间的变化。
推荐阅读:文章 Diff(维基百科)
与 SCCS 一样,RCS 不允许多个程序员同时处理同一个文件。在处理该文件时需要拥有一个文件并排除所有其他程序员在当时似乎是一个合理的限制,但它会使程序员团队的工作变得更加繁琐。
1986 年,Grune 开发了并发版本系统 (CVS)。与以前的系统不同,CVS 允许多个程序员同时处理同一个文件。它还采用客户端-服务器模型,允许单个目录存在于网络上,可供多个程序员同时访问。程序员可以在本地处理文件,但只要他没有将他的版本传输到服务器,其他开发人员就看不到它。
远程服务器还充当程序员的事实上的备份。即使所有程序员的计算机都被毁坏了,也可以用远程服务器上的代码重新开始。
在版本控制系统中,通常总是有一个最新版本。所有程序员都会对这个最新版本进行更改。然而,这种线性方法有其局限性。 CVS 更新的一项重要创新是分支的概念。分支允许组织可以并行发展的版本集。在此模型中,相同的文件实际上是重复的。然后有两个版本的文件(或两个以上)能够并行发展。按照惯例,通常默认使用一个主分支,并伴随着几个辅助分支。程序员可以随时创建新的分支。然后可以合并分支:如果将分支 A 分成两个被修改的分支(A 和 B),则可以将所有修改合并到一个分支中(合并 A 和 B)。分支概念在以下几种情况下很有用:
- 一些软件开发是投机性的。例如,程序员可能会探索一种新方法,但不确定它是否可行。在这种情况下,最好在单独的分支中工作并仅在成功的情况下与主分支合并。
- 出于安全原因,主分支可能仅限于某些程序员。在这种情况下,访问权限减少的程序员可能会被限制在单独的分支中。然后,具有特权访问权限的程序员可以在代码检查后合并二级分支。
- 分支可用于探索特定的错误及其修复。
- 分支可用于更新代码的先前版本。这样的版本可能会保持最新,因为一些用户依赖于该早期版本并希望接收某些修复。在这种情况下,辅助分支可能永远不会与主分支集成。
当项目包含多个文件和多个版本时,CVS 的缺点之一是性能不佳。 2000 年,提出了 Subversion (SVN) 作为 CVS 的替代方案,它满足相同的需求,但性能更好。
CVS 和 Subversion 受益于客户端-服务器方法,它允许多个程序员同时使用同一个版本目录。然而,程序员经常希望能够使用几个单独的远程目录。
为了满足这些需求,已经开发了各种“分布式版本控制系统”(DVCS)。最流行的可能是 Torvalds (2005) 开发的 Git 系统。 Torvalds 试图解决管理 Linux 源代码的问题。 Git 成为主要的版本管理工具。
工具。它已被谷歌、微软等采用。它是免费软件。
在分布式模型中,拥有代码本地副本的程序员可以将其与一个目录或另一个目录同步。他们可以轻松地在新服务器上创建远程目录的新副本。这种灵活性在许多复杂的项目中被认为是必不可少的,例如 Linux 操作系统内核。
有几家公司提供基于 Git 的服务,包括 GitHub。 GitHub 成立于 2008 年,拥有数千万用户。 2018 年,微软以 75 亿美元收购了 GitHub。
对于 CVS 和 Subversion,只有一组软件版本。使用分布式方法,多个集合可以在不同的服务器上共存。最终结果是软件项目可以在不同团队的责任下以不同的方式发展,并可能在未来进行协调。
从这个意义上说,Git 是分布式的。尽管许多用户依赖 GitHub(例如),但您的本地副本可以附加到任何远程目录,甚至可以附加到多个远程目录。动词“克隆”有时用于描述本地 Git 项目的恢复,因为它是所有文件、更改和分支的完整副本。
如果项目的副本附加到另一个远程目录,则称为 fork。我们经常区分分支和分叉。一个分支总是属于主项目。 fork 最初是项目的完整副本,包括所有分支。分叉可以重新加入主项目,但这不是必需的。
给定一个公开可用的 Git 目录,任何人都可以克隆它并开始处理它并为它做出贡献。我们可以创建一个新的分叉。从分叉中,我们可以提交一个拉取请求,邀请人们整合我们的更改。这允许一种形式的无许可创新。事实上,无需直接与作者交互即可检索代码、修改代码并提出新版本。
CVS 和 subversion 等系统可能会变得效率低下,并且需要几分钟才能执行某些操作。相比之下,Git 通常高效且快速,即使对于大型项目也是如此。 Git 是健壮的,不会轻易被“损坏”。但是,对于多媒体内容等大文件,不建议使用 Git:Git 的强项在于文本文件。应该注意的是,Git 的实现随着时间的推移而改进,并且包括复杂的索引技术。
Git 经常在命令行中使用。可以使用图形客户端。像 GitHub 这样的服务让 Git 更容易一些。
Git 的基本逻辑单元是commit
,它是对多个文件的一组更改。 commit
包括对至少一个父级的引用,但没有父级的第一个commit
除外。单个commit
可以是多个子级的父级:可以从一个commit
创建多个分支,并且每个后续commit
都成为初始commit
的child
级。此外,当合并多个分支时,生成的commit
将有多个父级。从这个意义上说, commits
形成了一个“无环有向图”。
使用 Git,我们希望能够使用唯一标识符以简单的方式引用commit
。也就是说,我们希望有一个短的数值,它对应于一个commit
和一个只commit
。我们可以为每个commit
分配一个版本号(1.0、2.0 等)。不幸的是,这种方法很难与commits
不形成线性链的事实相协调,其中commit
只有一个父级。作为替代方案,我们使用哈希函数来计算唯一标识符。散列函数将元素作为参数并计算数值(散列值)。有几个简单的哈希函数。例如,我们可以通过计算h = 31 * h + b
来迭代消息中包含的字节,从起始值h
开始,其中b
是字节值。例如,如果我们从h = 0
开始,包含字节 3 和 4 的消息可能具有31 * (31 * 3) + 4
的哈希值。这种简单的方法在某些情况下是有效的,但它允许恶意用户创建冲突:有可能创建具有相同哈希值的虚假commit
,从而产生安全漏洞。出于这个原因,Git 使用了由密码专家开发的更复杂的散列技术(SHA-1、SHA-256)。提交使用哈希值(例如,十六进制数值 921103db8259eb9de72f42db8b939895f5651489)标识,该哈希值是根据日期和时间、程序员留下的评论、用户名、父母和更改的性质计算得出的。理论上,两个commits
可能具有相同的哈希值,但考虑到 Git 使用的哈希函数,这不太可能发生。引用十六进制代码并不总是可行的。为了让事情变得更简单,Git 允许您使用标签来识别提交(例如,v1.0.0)。以下命令将执行: git tag -a v1.0.0 -m "version 1.0.0"
。
标签通常采用由点分隔的三个数字的形式:MAJOR.MINOR.PATCH(例如,1.2.3)。对于每个新版本,三个数字中的至少一个都会加 1。第一个数字通常从 1 开始,而接下来的两个数字则从 0 开始。
- 当您对代码进行重大更改时,必须增加第一个数字 (MAJOR)。其他两个数字(MINOR 和 PATCH)通常重置为零。例如,您可以从版本 1.2.3 转到版本 2.0.0。
- 第二个数字 (MINOR) 会增加较小的更改(例如,添加一个函数)。当增加第二个数字时,第一个数字(MAJOR)通常保持不变,最后一个数字(PATCH)重置为零。
- 修复错误时,最后一个数字 (PATCH) 会增加。其他两个数字没有增加。
这个约定有更好的版本,比如“语义版本控制”。
使用 Git,程序员可以拥有提交图的本地副本。他们可以添加新的commits
。在随后的步骤中,程序员必须将他的更改“推送”到远程目录,以便其他程序员可以看到这些更改。其他程序员可以使用“拉”来获取更改。
Git 具有高级协作功能。例如, git blame
命令让您知道谁最后修改了一段给定的代码。
结论
计算中的版本控制是一种复杂的方法,得益于多年的工作。可以以低成本存储同一文件的多个版本并快速从一个版本导航到另一个版本。
如果您在不使用 Git 等版本控制工具或同等工具的情况下开发代码,您就是在绕过经过验证的实践。如果您想与多个程序员一起处理复杂的项目,如果没有版本控制,您的工作效率可能会低得多。
原文: https://lemire.me/blog/2022/04/21/an-overview-of-version-control-in-programming/