跳转至

七:Linux 上的编程

本文已完稿并通过审阅,是正式版本。

导言

作为一个成熟而实用的系统,我们该如何在 Linux 上进行日常的编程开发呢? 这一章将解答一下几个问题:

  • Linux 上的 C/C++ 开发
  • Linux 上的 Python 开发
  • Linux 上编程语言开发的范式与共性

C 语言开发

C 语言是大学编程语言教学中几乎必定讲解的一门编程语言。 考虑到 Linux 内核即是用 C 语言编写的,在 Linux 上 C 语言拥有近乎系统级的支持。 在 Linux 上开发 C 语言(以及 C++)是一件非常轻松、方便的事。

从单文件开始

现在假设我们有一份源码文件 main.c,内容如下:

// main.c
#include <stdio.h>

int main() {
  printf("Hello World!\n");
  return 0;
}

这是一个简单的 Hello World 程序。我们如何使它变为一份二进制可执行文件呢?

在 Windows 或 Mac OS X 这样带 GUI 的系统上,通过安装 IDE,我们可以使用 IDE 中的编译功能来编译出目标。 实际上,这些带有图形界面的 IDE 的编译往往是封装了各种提供命令行接口的编译器。 自然,在众多无 GUI 的 Linux 上,我们同样可以调用这些提供命令行接口的编译器进行编译。

各平台常见编译器

Linux 上常用的编译器是 gcc 和 clang。 其中 gcc 是由 GNU 组织维护的,而 clang 是由 LLVM 组织维护的。

Windows 上常见的编译器则是 msvc,由微软维护。著名的 Visual C++ 即使用了 msvc。

Mac OS X 本身由 BSD 发展而来,也以 gcc 和 clang 为主。 值得一提的是,Mac OS X 上自带的 gcc 其实是 clang,在 Terminal 输入 gcc -v 即可发现。

这里我们使用 gcc 对这个文件进行编译,生成二进制文件:

$ gcc main.c -o main
$ ./main
Hello World!

这里用 -o 指定了输出的二进制文件的文件名 main

应当注意到 gcc main.c -o main 这条指令没有打印出任何内容。 这是因为整个编译过程是成功的,gcc 没有需要报告的内容,因此保持沉默。 这是 Unix 哲学的一部分:Rule of Silence: When a program has nothing surprising to say, it should say nothing.1

多文件的状况

只在一个文件中编写代码,对于稍微大的开发都是不够的: 对于个人维护的小项目尚可,但当你面临的是一个多人开发、模块复杂、功能繁多的大项目时(无论是在公司工程还是在实验室科研中,这都是普遍的情况), 拆分代码到多个文件才是一个明智(或者说可行)的做法。

C 语言的多文件实现

我们假设你对于 C 语言的多文件实现有着基本的认知: 即能够在之前的系统中的 IDE 内完成 C 语言的多文件开发。

如果你不会,别急,这里将做一个简单的介绍:

假设你拥有一下两个文件:

// main.c
#include "print.h"

int main() {
  print();
  return 0;
}
// print.c
#include "print.h"

#include <stdio.h>

void print() {
  printf("Hello World!\n");
}

那为了在 main.c 中调用 void print() 这个函数,你需要做以下几件事:

  • 在当前目录下新建一个头文件 print.h;
  • 在 print.h 中填入以下内容:
// print.h
#ifndef PRINT
#define PRINT

void print();

#endif  // PRINT

这里的 #ifndef ... #define ... #endif 是头文件保护,防止同一头文件被 #include 两次造成重复声明的错误, 如果你不理解这部分也没关系,只需保证 void print(); 这一行声明存在即可。

  • 在 main.c 和 print.c 中同时 #include "print.h"

这样,程序就可以被编译运行了。

假设我们有以下三个文件:

// main.c
int main() {
  print();
  return 0;
}
// print.c
#include <stdio.h>

void print() {
  printf("Hello World!\n");
}
// print.h
#ifndef PRINT
#define PRINT

void print();

#endif  // PRINT

我们将依次编译链接,生成目标的二进制可执行程序。让我们看一下命令:

$ gcc main.c -c  # 生成 main.o
$ gcc print.c -c  # 生成 print.o
$ gcc main.o print.o -o main
$ ./main
Hello World!

这里我们使用了 gcc -c-c 会将源文件编译为对象文件(Object file,.o 这一后缀就源自单词 object 的首字母)。 对象文件是二进制文件,不过它不可执行,因为其中需要引用外部代码的地方,是用占位数替代的,无法真正调用函数。

注意到我们没有添加 -o 选项,因为 -c 存在时 gcc 总会生成相同文件名(这里特指 basename,main.c 中的 main 部分)的 .o 对象文件。

生成了对象文件后,我们来进行链接,在相应函数调用的位置填上函数真正的地址,从而生成二进制可执行文件。 gcc 这一指令会根据输入文件的类型调用相应的程序完成整个编译流程。 在这里,虽然同样是 gcc 指令,但是由于输入的为 .o 文件,gcc 将调用链接器进行链接,从而生成最终的可执行文件。

同样是这个原因,实际上使用 gcc main.c print.c -o main 是可以一步到位,但在接下来的内容里,你会看到另一个方案。

gcc 的四个部分,编译的过程

gcc 的编译其实是四个过程的集合,分别是预处理(preprocessing)、编译(compilation)、汇编(assembly)、链接(linking), 分别由 cpp、cc1、ar、ld 这四个程序完成,gcc 是它们的封装。

这四个过程分别完成:处理 # 开头的预编译指令、将源码编译为汇编代码、将汇编代码编译为二进制代码、组合众多二进制代码生成可执行文件, 也可分别调用 gcc -Egcc -Sgcc -cgcc 来完成。

在这一过程中,文件经历了如下变化:main.cmain.imain.smain.omain

使用构建工具(Build tools)

上述方法在源文件较少时是比较方便的,但当我们面对的是数以千计万计的源文件(同样的,在工作或科研中这也是常见状况),我们将面临以下困难:

  • 手动地一一编译实在太麻烦,太浪费精力;
  • 这些源文件的编译有顺序要求,为了满足此依赖关系需要设计一个流程;
  • 编译整个项目需要难以忍受的大量时间,应当考虑到一部分未更改的源文件不需要重新编译。

为了让机器帮助程序员解决这些困难,构建工具应运而生。 同样的,由于需求巨大,构建工具在 Linux 上亦获得了强力支持。

Makefile

Makefile 是中小型项目常用的构建工具。 让我们考虑以下例子:

假设前述 3 份源文件已存在在当前目录下。 创建以下内容的文件,并命名为 Makefile

main.o: main.c print.h
print.o: print.c print.h
main: main.o print.o

然后在当前目录下执行:

$ make main
$ ./main
Hello World!

为了解释这一过程,我们来分析一下 Makefile 的内容。其中:

main.o: main.c print.h

这一行,通过冒号分割,指定了一个名为 main.o 的目标,其依赖为 main.cprint.h。 由于整个文件中没有名为 main.c 的目标,所以 Makefile 会认为对应的 main.c 文件为一个依赖,print.h 同理。

在指定了目标和依赖后,紧接着的下一行如果用 Tab 缩进,则可以指定利用依赖获得目标的指令。 例如:

main.o: main.c print.h
    gcc main.c -c  # 一定要用 Tab 缩进而不是 4 个 / 2 个空格——这是历史遗留问题。

以上内容表示如果要获得 main.o 这个目标,则会执行 gcc main.c -c 这个指令。 如果没有指定命令,Makefile 会尝试从文件后缀等处获取信息,推测你需要的指令。 例如此处即使不显式写出指令,Makefile 也知道用 gcc 来完成编译。

最终我们在 shell 中执行 make main,正是指定了一个最终目标。 如果不提供这个目标,Makefile 则会选择 Makefile 文件中第一目标。 为了获得最终目标,Makefile 会递归地获取依赖、执行指令。

Makefile 的亮点在于引入了文件间的依赖关系。 在使用它进行构建时,Makefile 可以根据文件间的依赖关系和文件更新时间,找出需要重新编译的文件。 在项目较大时这能明显节省构建所需的时间,同时也能解决一些由于编译链接顺序造成的问题。 相较与输入一大串指令,单个的 make [target] 甚至是仅仅 make,也更加优雅和方便。

其他的构建工具:CMake,ninja……

一个更大的工程可能有上万、上十万份源文件,如果一一写进 Makefile,那依然会异常痛苦,且几乎不可能维护。

为了更好的构建程序,大家想出了“套娃”的办法:用一个程序来生成构建所需的配置,CMake 则在这一想法下诞生。

CMake 在默认情况下,可以通过 cmake 命令生成 Makefile,再进一步进行 make

对于 CMake 的讲解已经超出了本课程的讲解范围。 CMake 作为一个足够成熟、也足够陈旧的工具,既有历史遗留问题,也有新时代下的新思路。 正如 C++ 和 Mordern C++,CMake 也有 Mordern CMake,更有像微软 vcpkg 那样新的辅助工具和解决方案。 如果你想了解 CMake 的一些知识,附录将会有简单的介绍,亦可以考虑看一些较新的、关于 Mordern CMake 的博客,以及官方的最新文档。

另一个值得一提的是 ninja。ninja 和 Makefile、autoconf 较类似,是构建工具,所属抽象层次低于 CMake。 ninja 的特点的是相较与 Makefile 更快,对于多线程编译的支持更好。 详细信息可以到 ninja 的官方网站查看。

至于 C++

C++ 的工具链与 C 的是相似的。

实际上,只需将上面内容中的 gcc 指令改为 g++,你就能同样地完成 C++ 的开发。 gcc 这一编译器本身即支持多种编程语言,包括了 C、C++、Objective C 等。 其他编译器如 clang 也会提供 clang++ 这样的指令完成 C++ 的编译。 Makefile、CMake 这样的构建工具亦可以用于多种编程语言。

总结

在 Linux 下,大多编程语言都会提供一套适合命令行的、简单便捷的工具链。 善于运用这些工具,能够极大地提升你的开发效率,支持你完成自己的项目。

Python 语言开发

Python 作为一门年长但恰逢新春的解释型语言,亦被业界广泛使用。 相较于 Windows,在 Linux 上开发 Python 要更加简单。

针对 Python 的介绍,我们将不会着力于具体代码,而是分析其一些外围架构,从而引出总结。

解释器 python

一般的 Python(CPython)程序的运行,依靠的是 Python 解释器(Interpreter)。 在 Python 解释器中,Python 代码首先被处理成一种字节码(Bytecode,与 JVM 运行的字节码不是一个东西,但有相似之处), 然后再交由 PVM(Python virtual machine)进行执行,从而实现跨平台和动态等特性。

由于使用过于广泛,几乎每一份 Linux 都带有 Python 解释器,以命令 python2python3 调用,分别对应两个版本的 Python。

包管理器 pip

为使用外部的第三方包,Python 提供了一个包管理器:pip。

pip 和 apt 之类的包管理器有相似之处:完成包的安装和管理,完成依赖的分析,等等。 不过 pip 管理的是 Python 包,可以在 Python 代码中使用这些包。让我们看下面的例子:

# 安装 Python 3 和 Python 3 的 pip。对于 Python 23 间的纠纠缠缠,我们将在之后讲解。
$ sudo apt install python3 python3-pip

# 测试一下看看,是否能够正常使用它们。
# 请保证在 `python``pip` 后有 3 这个数字。这也是历史遗留问题。
$ python3 -V
$ pip3 -V

# 暂时忽略以下两条指令,我们会在之后讲解。
$ python3 -m venv venv
$ source venv/bin/activate
(venv)$ ls
venv

# 安装一个 Python 包 a、b,以及 a、b 依赖的 Python 包。
(venv)$ pip3 install a b

# 卸载一个 Python 包 b。注意:这不会删除之前一起安装的包 b 的依赖。
(venv)$ pip3 uninstall b

安装了 a 之后,我们就能在代码中使用 a 这个包了。

# main.py
import a

print(a)
(venv)$ python3 main.py
<module 'a' from '...'>

这样,我们就完成了对外部 Python 包的安装和引用。

Python 依赖管理

一个软件一般含有众多依赖,尤其是对于追求易用、外部库众多的 Python 而言,使用外部库作为依赖是常事。

此处我们将尝试给出各种使用较多的 Python 依赖管理方案。

requirements.txt

在一些项目下,你可能会发现一个名为 requirement.txt 的文件,里面是一行行的 Python 包名和一些对于软件版本的限制,例如:

# requirements.txt
django
pytest>=3.0.0
pytest-cov==1.0.0

为了安装这些 Python 包,使用以下指令:

$ pip3 install -r requirements.txt

这将从 requirements.txt 文件中逐行读取包名和版本限制,并由 pip 完成安装。

此方案简单明了,易于使用,但对于依赖的处理能力不足。

setuptools:setup.py

在 PyPI,即 pip 获取 Python 包的来源中,使用 setuptools 是主流选择。 setuptools 不是 Python 官方的项目,但它已成为 Python 打包(packaging)的事实标准。

常见状况是目录下会有一个名为 setup.py 的文件。 要安装依赖,只需:

$ ls
setup.py
$ pip3 install .

这种方案特点是使用广泛,易于对接,能提供的信息和配置较全,但配置起来也较复杂。

其他的:pip-tools、pipenv……

pip-tools 可以看作对 requirements.txt 的增强。 它额外提供了 requirements.dev 文件,从而完成了对于依赖进行版本锁定的支持。

pipenv 则是一个更加全面的解决方案,它提供了类似于 npm 的配置文件和 lock 文件,对于依赖有非常强的管理功能。 但其完成度和工业中的稳定性尚有待证明。

Python 有非常多的依赖管理方案,某种意义上讲是自带的 pip 管理功能不足所造成的。 一般而言,只需熟悉常用的 requirements.txt 和 setuptools 方案即可。

Virtualenv

让我们考虑以下情况:

Python 通过 pip 安装的包,默认安装在系统目录 /usr/lib/python[version] 下, 当传入了一个 --user 选项时,则会安装在用户目录 ~/.local/lib/python[version] 下。 当普通地运行 Python 解释器时,这两个目录下的包均可见。

现在假设用户目录下已有一个包 a,版本为 1.0.0。 现在我们需要开发一个程序,也需要包 a,但要求版本大于 2.0.0

由于 pip 不允许同时安装不同版本的同一个包,当你运行 pip3 install a>=2.0.0 时,pip 会更新 a2.0.0, 那原先依赖于 a==1.0.0 的软件就无法正常运行了。

注意 >=

在一些 Shell(如 zsh)中,>= 有特殊含义。 此时上述命令应用引号包裹 >= 部分,如 pip3 install 'a>=2.0.0'

为了解决这一问题,允许不同软件使用不同版本的包,Python 提供了 Virtualenv 这个工具。 其使用方法如下:

一般 Virtualenv 会带在默认安装的 Python 中。 如果没有,可以用 sudo apt install python3-venv 来安装。

常见的做法是使用 Python 的模块运行来完成在 Shell 中的执行:

$ python3 -m venv venv

以上指令中,-m 表示运行一个指定的模块,前一个 venv 指运行 venv 这个包的主模块 __main__, 后一个 venv 是参数,为生成目录的路径。 这将使 venv 在当前目录下生成一个名为 venv 的目录。

在一般的 shell 环境下,我们将使用 source venv/bin/activate 来启用这个 venv。

完成以上操作后,你就进入了当前目录下 venv 文件夹所对应的 Virtualenv。 此时,你使用 pip3 install 安装的 Python 包将会被安装在 venv 这个文件夹中, 这些包也只有在你 source venv/bin/activate 之后才可见,外部无法找到这些包。 通过 deactivate 可以退出 Virtualenv,回到之前的环境中。

实际上,由于 Python 是借助一些环境变量来完成包搜索的步骤的,source venv/bin/activate 其实是配置了一些环境变量,从而达到目的。这样,就实现了程序间依赖的隔离。

Python 的版本

正如我们之前所讲,Python 不是一个新的编程语言。 现在的 Python,最新的版本已到 3.8。 实际上还在使用中的 Python,主要在 2.7、3.5——3.8 这个区间内。

Python 2 到 3 某种程度上讲不是变革,实际上 Python 2 和 3 基本可以看作两个不同的编程语言。 在从 2 到 3 的升级中,一方面众多底层语法都发生了改变,使得迁移异常麻烦。 另一方面,由于 Python 2 的盛行,程序 python 普遍指向 python2。 因此当 Python 3 出现时,为了有效区分两者,调用解释器时我们需要特地使用 python3 这一指令。 尽管在某些平台(例如 Arch 系 Linux)上,python 己经变为指向 python3, 但考虑到 Ubuntu、CentOS、Debian 等发行版上 python 仍指向 python2, 显式地指定一个版本是更明智的选择。

实际上,Python 2 已在 2020 年初正式宣告停止维护, 现在如果我们要使用 Python,最好使用 3 版本。

而在 Python 3.x 版本中,3.5 亦将在今年年底 EOL(end of life), 因此实际上选用 Python 3.6 及以上者更稳妥。

Python 的其他实现

Python 作为一门编程语言,官方的实现是 CPython,我们一般使用的、成为事实标准的就是这个。 CPython 中的 C 是指此解释器是用 C 实现。

相应的,Python 还有其他的一些实现:

  • JPython:将 Python 编译到 Java 字节码,由 JVM 来运行;
  • PyPy:相较于 CPython,实现了 JIT(just in time)编译器,性能有极大地提升;
  • Cython:引入了额外的语法和严密的类型系统,性能也有很大提升;
  • Numba:将 Python 编译到机器码,从而直接运行,性能也不错。

视情况使用不同的 Python 实现能够很大程度地提升性能。 但如果你不确定自己的意向,且性能需求不大,使用官方的 CPython 也是明智之选。

总结

外部包引用和依赖管理是程序开发中必不可少的部分。 如果官方有成熟的方案,跟随他们是明智的选择。 否则则需根据实际情况,按需选用。

思考题

试试 Rust

Rust 是一门新兴编译型编程语言。 尝试查询 Rust 的文档,指出 Rust 的编译器、依赖管理程序, 介绍一下如何将 Rust 源码变为可执行程序,如何在 Rust 中引用外部包。

引用来源