怎么为一个陌生的项目添加功能

在学习完一门编程语言的语法后,“怎么阅读项目代码” 无疑是一个经常会被问起的问题。

“明明循环分支赋值调用我都认识,怎么就是看不懂代码在说什么?”

how-could-it-be

是啊,为什么呢?

在语法和项目之间无疑缺失了一部分内容,我之前猜想可能缺失的是是软件工程,但实际上在学习软件工程这门课后我还是没有听到这个问题的答案。本文我提供一些浅薄的看法。


Building: 在阅读代码之前

首先需要注意的是,不要直接点开代码开始看,这是鲁莽的思路,一不小心你就会陷入各种调用、抽象、回调和宏定义的陷阱中。

为了迅速了解一个项目,首先需要看的是项目的文档,如果文档把项目的架构写的很清楚,那么皆大欢喜,你可以跟随文档进行学习。

常见的项目目录与意义

当然事情不会那么容易,大部分的程序员都是不会认真写文档的,如果你觉得文档不知所云,最好的办法是观察项目的结构 —— 简单来说就是文件夹的组织,典型的源码架构会有比较明确的树形文件划分。你可以通过 build/script/util/core/dependency 等目录的名字看出源码职责的宏观划分。当然也可能所有的源码都堆积在源码根目录下。

下面列举我见到的一些通用的文件夹命名。

  • src: source,指源码,通常是项目的核心部分。

  • include: 通常是类 C 式的头文件或者公共接口的声明

  • lib:library,指编译好的静态 / 动态库

  • test:单元测试、集成测试代码

  • docs:项目相关的文档

  • build:编译过程生成的中间文件

  • scripts:辅助脚本,比如构建、部署、格式化等,以 .py.sh 居多

  • assets:静态资源(图片、文本等)

  • util:工具函数集合,其逻辑通常独立于项目

  • dependency:处理外部依赖。

当然,这不是金科玉律,很多项目有自己的习惯。

常见的配置文件

在了解完文件夹的结构后,初次接触项目的时候,我会在根目录下看到各种莫名其妙的文件。这些有的与项目的架构有关,有的与协作开发有关,有的与自动化测试有关。此处我们将它们统称为配置文件,下面列举一些常见的配置文件。

  • README.md:项目的门面,用 Markdown 编写,通常包含项目简介,必看

  • LICENSE:开源协议。

  • .git:Git 的本地版本数据库,存着所有提交记录、分支和标签。

  • .gitignore:列出不需要提交到仓库的文件。

  • .gitkeep:一个 "占位空文件"。其作用是让某个空文件夹保留在仓库里

  • .gitattributes:Git 的文件属性设置,通常用来规范化换行符。

  • .gitmodules:当项目依赖另一个独立的 Git 仓库时,会通过它记录依赖的远程地址和本地路径。

  • .github/:一个文件夹,存放 GitHub 专用的自动化配置。

  • workflows/*.yml:CI/CD 自动构建 / 测试。

  • package.json:Node.js 项目,声明项目依赖,从中能知道项目用的是什么框架。

  • pom.xml:Java Maven 项目,声明 Java 依赖包和插件。

  • requirements.txt:Python 项目的传统依赖列表。

  • cargo.toml:Rust 项目,声明依赖 crate、项目元数据、特性开关。

  • Makefile:快捷命令脚本,包含一系列编译、清理、安装的 Shell 命令,常用于 C/C++。

  • eslintrc:JavaScript/TypeScript 的代码检查规则。

  • .clang-format:C/C++/Objective-C 的代码格式化规则。

  • .editorconfig:跨 IDE 的基础编辑规范。

  • .vscode/:存放 VSCode 编辑器的本地工作区配置。

  • dockerfile:构建容器镜像的配置。

  • docker-compose.yml:多容器编排的启动配置。

  • .dockerignore:作用类似于 .gitignore,告诉 Docker 在构建镜像时忽略哪些文件

  • vite.config.js:前端构建工具的配置文件。

  • .env.example:环境变量模板文件,通常需要将其复制一份命名为.env

  • commitlint.config.js:限制 Git 提交信息的格式。

  • pubspec.yaml:Flutter 项目的依赖管理和资源配置。

  • CMakeLists.txt:CMake 构建脚本。用于生成 Makefile 或 IDE 工程文件。

  • configure :配置脚本,运行 ./configure 会检查系统环境,生成 Makefile。

一时间大概能想到的是这些,看到这些文件基本就知道项目使用的编程语言与框架。可以说是相关技术的信号灯了。

试着运行项目

在完成初步探索后,需要试着先运行一次项目,切实的理解项目具体做了什么。在这个过程中,你可能需要下载相关的编程语言的编译器,运行时,插件,语法检查工具,构建工具等等。在安装前,务必在上一节的各个配置文件中找到其依赖的版本,严格按照项目指定的版本安装。如果项目依赖 MySQL 等数据库服务,你需要安装并配置好数据库服务。

在安装好语言环境后,大部分的现代语言都靠包管理器拉取依赖。Node.js 使用 npm,Python 使用 pip,Java 使用 mvngradle ,Rust 使用 cargo 等等。

依赖装好后,项目大概率会在启动时报错,这是因为没有配置项目本身的配置文件。这可能包括字面包含 config 的配置文件,.env 环境变量文件,APIKEYDB_PASSWD 等常量值的指定。

依赖和配置都有了,但数据库可能是空的。需要执行初始化脚本建表,通常这会在script目录下,如果没有,需要阅读源码手动建表。

整理开发环境

在代码成功跑通后,可以根据自己的需要整理一下开发环境。

我个人习惯使用 VSCode,其有以下比较实用的配置,我习惯在.vscode/settings.json 中配置它们。

  • files.exclude:用于从文件资源管理器(Explorer)中隐藏文件。
  • search.exclude:从全局搜索(Ctrl+Shift+F)中排除文件。
  • files.watcherExclude:从文件监视中排除文件。

好了,我们终于可以开始读代码了!


Guessing:怎么开始读代码

一个正统的方法是用 main 函数自顶向下阅读,权衡深度和广度遍历所有调用栈。这无疑是非常扎实的方法。这确实能从根本上吃透整个项目,但是这需要难以想象的时间和精力,而且很容易陷入到与功能无关的代码中。

另一种探索源码的方法是猜测,这是一种猜测源码的实现形式,搜索定位相关部分后,阅读对应上下文和调用栈的形式。一个非常常见的定位手段是常量字符串的定位手段,即通过检索程序报错信息或者处理的常量来定位代码。(当然,AI Agent 已经可以帮我们完成这件任务了)

定位到可能的位置后,我们修改源代码,使用 print 输出一些语句,重新编译(当然现代语言也几乎都支持断点调试)。情况 1 是无法进行编译,我们就可以顺着编译器的报错信息摸清楚函数的调用栈。情况 2 是通过了编译,那么我们就可以输出一些更有用的信息来探索定位点附近的功能。Guessing 为我们节省了大量定位代码的时间,所以在接下来,我们需要对定位的功能代码进行精细的分析才能进行进一步的修改。

常见的软件模式

当然猜的准不准还是需要大量的实践积累,常见的软件模式有:

FullWeb:前后端分离,浏览器访问页面,前端调用后端 API。

C/S:客户端自带界面,通过网络与中心服务器通信。

frontend:没有后端逻辑,主要做 UI 渲染、状态管理和本地存储

GUI:没有 "网络" 和 "数据库" 的纯界面开发。

CLI:纯命令行程序。

常见的代码结构

探索软件的功能,通常 "使用了那些数据" 以及 "使用了那些数据结构处理" 是问题的关键。前者需要直觉和相关领域的算法实现,后者则与约定俗成的代码模式有关。我大概见过这些:

  • 分层架构:代码被垂直划分为若干层,常见如 Controller → Service → Repository/DAO。

  • MVC:Model(数据)、View(展示)、Controller(控制逻辑)三分。

  • 事件驱动:没有直接的请求 - 响应链路,代码通过发送和监听事件(Message)来触发。

  • Kernel 式的分发:存在一个无限循环,循环内部每次迭代(一帧)会执行一组固定的步骤。常见于游戏开发。

  • 监听事件:程序启动时会执行一次初始渲染,通过事件回调触发状态变更,常见于前端框架。

  • 状态机:系统在不同的状态(IDLE, RUNNING, ERROR)之间转移,由事件触发转换。

  • Consumer Producer:代码里有一个队列。一部分代码往里丢数据,另一部分代码从中取数据并处理。

  • 响应式:通过描述数据流的转换来替代数据的更新。

尽可能提高编译效率

在使用的工具不够便捷的时候,很容易就把时间浪费到编译过程中去了,所以在第一次完成整个项目的编译运行后,可以通过编写构建脚本来提高编译运行的效率。这一过程我主要使用的有 Makefile.vscode/tasks.json

Makefile 是一种能定义文件依赖关系和编译命令的脚本,在 C/C++ 项目中广泛使用,但是其本质是一个通用的任务自动化运行器。

tasks.json 是 VSCode 的任务配置文件,本质是执行预定义的 shell 命令。那么不难就可以在使用 Makefile 的基础上配置一个 make 快捷键,大大提升效率。


Feature:怎么添加功能

在实际的软件开发过程中,一旦有了原型,接下来的所有时间都在调整软件的功能,添加正确的功能才是最终要达成的目的。

定位目标代码

为了给软件添加功能,第一个要做的事情就是定位,这与第二节我们讲的部分略有相似之处。我们通过观察软件的行为,通过检索 "锚点",就可以模糊的定位到软件的函数栈附近,随后经过合适的跳转就可以定位到目标位置的上下文。

Q:什么是 "锚点",怎么检索它?

"锚点" 是一个比喻,指的是代码中具有唯一性的常量,宏定义,和我们通过观察日志猜测的代码模式。因为错误信息几乎不会改动,它们非常直白,让你能以此为锚点。

比如运行软件时,界面弹窗或终端输出:

"Failed to allocate memory for texture: 11037"

那么,直接在代码库中搜索 "Failed to allocate memory for texture" 就可以定位到 texture Memory allocator 的相关代码,进而就可以定位到调用 allocator 前的 initial 函数等等。

模仿相似的功能

根据人类的直觉,相似的功能往往会使用相似的函数,相似的代码结构和相近的位置。所以在实现功能的时候,不妨先定位代码中相似的功能,捋清其逻辑后,再顺着其的调用栈一层层魔改。

假设我们要给一个类似 UNIX 的内核添加一个 get_current_task_name() 系统调用,用于获取当前运行进程的名字。按照 "相似功能用相似函数" 的直觉,我们先在源码里搜索 "进程名" 相关的现成函数。很快会发现现有的 getpid()getppid() 系统调用。

但是在模仿的过程中有一件事要特别注意。

尽早编译成功

在修改项目代码后,一个很常见的问题是随着改动不断增加,报错也不断增加,最后在项目上捅出一个越来越大的窟窿。

有两件重要的法则:一个是尽早编译成功,一个是切割无关功能。

永远不要试图一次添加多个功能,这会混淆你对代码的掌控度。如果发现某些无关函数因为你的改动而报错,只需要通过一些简单的改动维持住它们现有的功能并标记 TODO 即可。

错误越晚发现,修复成本越高。在将代码修改一定程度后,立刻启动编译,逐次修复所有的 error 并测试新的功能。这种方法也被称之为 "步行骨架"(Walking Skeleton)。

walking-skeleton

Make it work, then make it right, then make it fast.

利用好版本管理

一个典型的错误做法是:憋一整天,改了几十个文件,最后 git add . 一把梭。(虽然但是很对不起我也这么干,vibe-coding 害了我😭)

正确的做法是在 3.3 完成一次最小的功能验证且编译通过后,立刻提交并在 commit message 中注明改动。这也被称为原子化提交(Atomic Commit),这样做的好处有很多,一个显而易见的优势是万一后续改崩了,git revert 只会丢掉最近几分钟的工作。

另一个好的做法是利用 git 的分支,通过切一个新的分支:git checkout -b feat/hatsunemiku 完成改动,这样就可以放心地在一个与主分支毫不影响的分支上工作。

目前大概能想到这些,欢迎在评论区中发表您的见解。

感谢您的观看!