将库/CLI 应用程序从 C 移植到 Go
“从 C 语言移植库给了我绝佳的机会来检验它,并理解这些语言之间的差异以及 Go 带来了什么。”
对我来说,学习新事物的最好方法是全身心投入,立即行动。虽然我已经编程了大部分时间,但我在 Go 世界中算是一个新人。为了真正掌握一些概念,我想承担一个小型、独立的、我可以实际完成的项目。
我做过很多 C 语言编程,Go 和 C 之间的相似之处促使我考虑将一个库从 C 移植到 Go。为了好玩,我最终选择移植 `AnsiLove/C`——一个将 BBS 时代的旧式 ANSI 艺术转换为 PNG 的库。我已经将其移植到一个名为 `go-ansi` 的新包中,该包可在 GitHub 上找到 (https://github.com/ActiveState/go-ansi)。
Go 的定位之一是作为一种现代系统语言,但没有像 C 语言那样繁琐、复杂和覆盖随机内存的危险。从 C 移植一个库给了我绝佳的机会来检验它,并理解这些语言之间的差异以及 Go 带来了什么。
程序结构,或者“头文件,哦对,我忘了那些。”
我遇到的第一件事是理解如何构建我的程序。查看 C 代码,首先想起的是:“哦,对了,头文件。” 我从事游戏开发很久了,显然 C/C++ 占据主导地位,但近年来 C# 占据了越来越大的市场份额,我很久没看到头文件了。像几乎所有其他现代语言一样,Go 也不需要头文件。
结论:C 语言有大量的样板代码和面向编译器的代码。
除了少数具有严格定义的头文件外,几乎所有这些代码都可以扔掉。函数原型?没了!头文件保护和其他预处理器指令?没了!
我遇到的另一个即时代码删减是大量用于字符串处理的实用函数。Go 优秀的标准库开箱即用,包含此功能,令人惊讶的是,我根本不需要查看那么多代码,因为这些功能已经由字符串库或 Go 内置的切片功能覆盖了。
在 Go 方面,社区内部对于如何最好地组织程序一直存在争论。鉴于 `AnsiLove/C` 是一个命令行应用程序,我知道这次移植会包含一个命令行实用程序,但 Go 面向包的设计也让我认为将功能作为库暴露出来,供其他人在其程序中使用会是理想选择。
我阅读了 Go 博客 (https://blog.golang.org/organizing-go-code) 上关于程序结构的许多精彩讨论,威廉·肯尼迪的讨论 (https://www.goinggo.net/2017/02/package-oriented-design.html, https://www.goinggo.net/2017/02/design-philosophy-on-packaging.html),并研究了许多流行的 Go 包的仓库,以了解最佳结构是什么。最终,`go-ansi` 的结构相对简单
主要的 `go-ansi` 库位于仓库的根文件夹中,程序的逻辑部分被分解到各自的文件中。将核心库文件放在根文件夹中的主要原因是 Go 包命名和库命名的约定是包名与它在文件系统中所包含的文件夹名匹配。命令行应用程序位于“cmd”文件夹中,这是放置命令行实用程序的另一个约定。其他“特殊”文件夹包括:internal 和 vendor。“internal”文件夹用于包内部但可能被其他组件使用的库,“vendor”文件夹用于第三方依赖项。
惯例的重要性以及它们如何影响您的开发,是我在开始时“知道”但并未内化的东西,但正如我们将看到的,一旦您理解它们,它们将大大简化开发。
在 C 语言中,`#include` 语句没有真正的语义力量,它是一个预处理器指令,基本上将整个文件吸收到您当前的源文件中(因此使用了头文件保护指令,这对我来说总是感觉像是一种“hack”),有效地为编译器创建了一个单一的巨型源文件。您的文件在磁盘上的结构与 C 编译器对其操作方式之间没有关联。
在 Go 中,情况并非如此,这对于新手来说可能很难理解。首先,Go 的工具链将任何目录/文件夹视为一个包,并且还会生成与它所包含的目录同名的库或可执行文件。此外,Go 中的 `import` 语句不仅与磁盘上的文件系统有直接连接,还与托管源代码的远程仓库有直接连接。
这确实是一个思维转变,但一旦你开始进入 Go 的思维模式,这些约定实际上会减少开销、样板代码,并普遍让你的生活更简单。
内存管理
内存管理可能是 C 语言中最令人望而生畏的方面,也是无休止的深夜调试会话试图找出随机崩溃或内存泄漏的原因。一想到调试、创建解决方案和优化内存管理所消耗的人工时,我就不寒而栗。
虽然不完全正确,但在大多数情况下,使用 Go 时你根本不必担心这个问题。我第一次在 AnsiLove 中遇到 malloc 和 realloc 时,我的想法是,“我……我想我可以直接删除这个?” 然后我兴高采烈地浏览了其余文件,意识到即使在这种小程序中也是首要问题的问题,都被 Go 的设计完全抽象掉了。
原始 C 代码
// write current character in ansiChar structure
if (!fontData.isAmigaFont || (current_character != 12 && current_character != 13))
{
// reallocate structure array memory
temp = realloc(ansi_buffer, (structIndex + 1) * sizeof(struct ansiChar));
ansi_buffer = temp;
ansi_buffer[structIndex].color_background = color_background;
ansi_buffer[structIndex].color_foreground = color_foreground;
ansi_buffer[structIndex].current_character = current_character;
ansi_buffer[structIndex].bold = bold;
ansi_buffer[structIndex].italics = italics;
ansi_buffer[structIndex].underline = underline;
ansi_buffer[structIndex].position_x = position_x;
ansi_buffer[structIndex].position_y = position_y;
structIndex++;
position_x++;
}
在原始 C 代码中,您可以看到存在缓冲区重新分配、直接缓冲区索引和手动复制内存的实例。这里的 realloc() 调用是“金发姑娘代码”——如果一切不尽如人意,就会发生非常糟糕的事情。而在下面的 Go 代码中,所有这些“危险”代码都被一个简单的 append() 调用取代,这很难出错。
重构后的 Go 代码
// write current character in ansiChar structure
if !f.isAmigaFont || (currentChar != 12 && currentChar != 13) {
var newChar ansiChar
newChar.colorBackground = colorBackground
newChar.colorForeground = colorForeground
newChar.colorFg24 = fg24
newChar.colorBg24 = bg24
newChar.currentChar = currentChar
newChar.bold = bold
newChar.italics = italics
newChar.underline = underline
newChar.positionX = positionX
newChar.positionY = positionY
ansiBuffer = append(ansiBuffer, newChar)
fg24 = color.RGBA{0, 0, 0, 0}
bg24 = color.RGBA{0, 0, 0, 0}
structIndex++
positionX++
};
在 Go 代码中,您可以看到所有的内存操作都被 `append` 调用取代了。代码稍微长一些,因为这里实现了额外的功能来支持 24 位颜色。还有进一步的低垂果实可以优化——例如,`structIndex` 变量很可能被 `len()` 调用取代。
字符串和切片
还记得我说的那些甚至不需要移植的字符串代码吗?我甚至不太需要使用“strings”库,因为 Go 内置的切片支持非常强大和方便。通过使用切片,子字符串变得微不足道
<code>seqGrab = string(inputFileBuffer[loop+2 : loop+2+ansiSequenceLoop] </code>
在 Go 中,类型转换也很强大,它基本上是通过使用前一个对象作为初始化器来“构造”一个新对象。在这一行中,我正在从文件中获取字节数组的一个子集,并将其转换为字符串。它稍后被转换为 int,在另一个模块中,我使用了不同的技术,该技术使用二进制包将字节直接读取为 int 类型。
此外,C 语言中有空终止字符串。是的,我也是。
并非一切都美好
尽管 Go 的简洁和强大令我惊叹,但正如我之前提到的,Go 设计选择的一些固执己见需要一点时间来适应
- {不能以换行符开头——这意味着如果你不是 functionName () { 的人,你需要更改 IDE 的设置。还有你的大脑。
- 分号的省略很受欢迎,但旧习惯难以改变。然而,与 { 不同,保留它们不会导致编译错误。
- 类型定义顺序,与几乎所有其他语言相反(例如 userName string vs. string userName),确实需要一些时间来适应。这很简单,但需要时间来忘却。
- 默认情况下,未使用的变量是错误,我确信这是可以配置的,但这确实使重构有点烦人。然而,总的来说,这实际上是件好事™,因为否则未使用的变量将永远存在于代码中,这不仅会产生膨胀,还会产生意想不到的后果和副作用。
- 依赖管理是一个显而易见的问题。我在本地使用 'dep' 来将这个库唯一的依赖项(图像大小调整)供应商化,但无法提交清单,因为该工具仍在开发中。在 import 语句中使用 repo 路径,加上供应商化的包版本被认为是完全独立的,因为它们实际上在不同的命名空间中,这意味着您不能拥有包的本地修改版本,即使它共享完全相同的接口。
各种发现与见解
我非常喜欢这个项目——使用 Go 令人耳目一新,对于有 C 背景的人来说,这是一个非常受欢迎的改进。有一些领域我没有机会探索,以及我在过程中获得的一些见解,在此总结如下
- 约定使编码更快,减少样板代码并增加简洁性。这在其他一些框架(例如 Ruby on Rails)中很流行,但将其作为核心语言特性出现很有趣。
- 像 `gofmt` 这样的工具确实让编写代码更快——少输入东西,少担心繁琐的格式化任务——包括它通过将代码制表符化来“解决”空格与制表符的困境。 `Goimports` 非常出色,因为它会根据您的包使用情况自动添加和删除导入语句。
- 编译时间几乎是瞬时的,这意味着您会实时收到语法错误。
- 从命令式语言 (C) 移植到大部分命令式语言 (Go) 很简单,而从 C++ 移植可能更困难,因为您可能需要使用函数组合和接口来近似相同的功能——这需要更多的语言熟悉度。
- 这基本上是一个 1:1 的移植,但在许多方面它可以更符合 Go 的习惯,而我没有处理(主要是由于时间限制)。例如,在这个练习中我没有明确使用任何接口,但我觉得这里有机会修改每个文件格式解析器的工作方式,以实现一个接口,而不是为每种文件类型调用一个自定义函数。
- 我也没机会测试并发。但是,我在另一个项目中使用了它,可以说,当涉及到线程同步等操作时,我简直是说:“就这样?”
- 我渴望探索和学习更多关于这门语言,这意味着我在缓冲区 -> 字符串到整数,或直接从字节到整数的转换中不一致,所以这很可能需要清理。
- Go 的 `Flags` 包的使用极大地简化了命令行解析。纯粹由于时间限制,我保留了现有的“帮助”文本,但我也可以利用内置的帮助消息来消除对大量 `printf` 的需求。
- 我在仓库中包含了示例文件(见下面的链接),但没有为其构建测试。为这个包构建一个测试套件绝对是我待办事项清单上的重中之重。
结束语
我使用 Go 进行这个项目时的总体感觉是,对于有 C/C++ 背景的人来说,它非常熟悉和舒适。主要的启示是发现我可以丢弃多少代码,以及用 Go 编写代码感觉多么快速(和安全)。
我最终意识到,编写“自然”Go 代码有一套特定的惯用法,而 1:1 的移植在某些地方感觉有点生硬。然而,即使作为直接移植,Go 代码也比原始 C 代码更简洁、紧凑和灵活。这并不是对原始代码的批评,也无意贬低它,更多的是对 Go 及其语法和惯例的本质的评论。仅仅消除内存管理和样板代码就足以让我认真考虑在下一个项目中使用 Go。
我本来希望能有机会探索并发和其他一些语言特性。随着经验的增加,利用更多语言特性的机会将会出现,我期待在未来几个月和几年里编写更多 Go 代码。
GO-ANSI 仓库
如果您想查看我称之为 `go-ansi` 的这个小工具的代码,可以在这里克隆/fork 它:https://github.com/ActiveState/go-ansi。
这只是一个初始版本,所以请随时在问题跟踪器中报告您可能遇到的问题,或者如果您在项目中使用了它,请告诉我。 :-)
感谢 Stefan Vogt、Brian Cassidy 和 Frederic Cambus 创建了原始的 `Ansilove/C` 库,本库基于此。