很多老项目里,Pascal 语言还坚挺地活着,尤其是 Delphi 写的那些业务系统。平时用着还行,可真要动手改点东西,各种别扭就全冒出来了。最让人头疼的,不是逻辑有多绕,而是语法和现代开发工具之间,像是隔着一道无形的墙,看得见,摸不着,跨不过去。

一、老代码的第一道坎:编译环境本身

1.1 版本太老,系统不认

以前写 Pascal 的人,很多用的是 Delphi 7 或者更早的版本。这些软件当年在 Windows XP 上跑得飞快,但现在你要是装到 Win10 或者 Win11 上,光安装过程就能折腾半天。装好了,打开工程,立马蹦出一堆“找不到文件”或者“路径错误”的框。这不是代码的问题,是老编译器不认识新系统的目录结构。

再看现在的新机器,动不动就十六核、三十二G内存,硬盘也是 NVMe 的,可老编译器还是单线程在那儿慢慢编译,一个上千个文件的工程,能给你编译十几分钟。真要加个新功能,光是等编译就够喝一壶了。

1.2 新系统下的字符编码坑

早年写 Pascal 的老前辈,用的都是单字节字符集,什么 GBK、Big5 这类。代码里到处都是 #230 这种魔数,代表某个中文字符。现在新系统默认都是 UTF-8,老代码拿到新环境下,注释里的中文全变成乱码,字符串里的中文在界面上也成了“锟斤拷”。更要命的是,Delphi 7 本身的 IDE 不支持 UTF-8 源文件,你要是用新编辑器存成 UTF-8,再回去用 Delphi 7 打开,整个文件直接崩掉。

二、语法规则跟现代工具较劲

2.1 单元文件的组织方式

Pascal 的单元文件分两部分,interface(接口)和 implementation(实现),这个设计其实挺清晰,但跟现代 IDE 的自动补全、代码导航功能却经常对不上。现代 IDE 喜欢把声明和定义放在一起,比如 Java 和 C#,找到方法直接跳转。Pascal 里你点一个函数名,IDE 先跳到一个声明,再跳一次才到实现,有时候还会跳错地方,因为老代码里声明和实现的参数名不一致,或者用了 overload 搞得 IDE 晕头转向。

看个典型例子,这是 Delphi 里的单元结构:

// 技术栈: Delphi Pascal

unit OrdersUnit;

interface

uses
  SysUtils, Classes, DB;

type
  TOrder = class
  private
    FOrderNo: string;
    FAmount: Double;
  public
    // 对外暴露的方法
    procedure Save;
  end;

implementation

procedure TOrder.Save;
begin
  // 这里才是真正的保存逻辑
  // 老代码里常连着数据库操作
  WriteLn('Saving order: ' + FOrderNo);
end;

end.

这里问题就来了。如果你用 Visual Studio Code 装个 Pascal 插件,它想给你智能提示,得先解析整个单元的 interface 部分,然后再找 implementation。老代码里只要有一处 ifdef 或者 if 编译器指令没配对,解析器就懵了,提示功能直接报废。

2.2 begin...end 跟大括号的恩怨

现代语言用 {} 来圈代码块,Pascal 用 begin...end。这个差异看着小,实际影响特别大。现代 IDE 的自动缩进、括号匹配,都是按照 {} 设计的逻辑来的。你要是让 IDE 格式化一段 Pascal 代码,它经常把 begin 跟后面的语句怼到同一行,或者 end 后面忘了加分号,结果编译报错,你得手工挨个调。

更烦人的是 end 后面有的带 ;,有的带 .,有的啥也没有。比如主程序末尾的 end.,你一不小心删了那个点,整个程序就成库文件了,编译的时候报“Program or unit ‘xxx’ recursively uses itself” 这种莫名其妙的错。

看这段真实的老代码:

// 技术栈: Turbo Pascal for DOS

program Demo;

var
  i: Integer;

begin
  for i := 1 to 10 do
  begin
    writeln('Number: ', i);
  end;
end.

这段代码在 DOS 的 Turbo Pascal 3.0 里没问题。但你把它放到现代 CI 服务器上,想用 GitLab Runner 跑一下,得先给它装个 DOSBox,再在 YAML 里写一堆非标准命令。而现代 IDE 根本看不懂 .pas 文件里没有 .dpr 或者 .dpk 上下文的情况,你要是单独打开这个文件,连语法高亮都经常失效。

2.3 指针和字符串的老习惯

老程序员写 Pascal 特别喜欢用 PChar 和定长字符串,因为当年内存紧张,能省就省。现在看起来,这些代码就是维护的噩梦。比如下面这段:

// 技术栈: Delphi 6

procedure ModifyStr(AMsg: PChar);
var
  Temp: array[0..255] of Char;
begin
  // 直接把内容复制到局部变量里
  StrCopy(Temp, AMsg);
  // 然后改一改
  StrCat(Temp, ' [Done]');
  WriteLn(Temp);
end;

这段代码一放出来,现代做代码审查的人立刻会问:为什么不直接用 string?为什么不用 TStringBuilder?可老版本 Delphi 里,string 在某些操作上特别慢,尤其大量拼接的时候,用 PChar 确实快。现在你要是重构这段代码,得先把所有调用它的地方都找出来,看看有没有人把 string 强转成 PChar 的,一改可能就引发内存泄漏或者访问冲突。

2.4 编译指令和条件编译

老项目里到处是 {$IFDEF WIN32}、{$IFDEF VER150} 这样的指令。当年是为了兼容不同版本的 Delphi 和操作系统。现在这个问题被无限放大,因为现代编译器和包管理器根本不管你这套。你想把老项目迁移到 Lazarus 或者 Free Pascal 上,光是整理这些编译指令就能花掉好几天。

这有个例子,非常典型:

// 技术栈: Delphi 2007

unit ConfigUtil;

interface

function GetAppVersion: string;

implementation

uses
  SysUtils;

function GetAppVersion: string;
begin
  // 不同版本用不同方式获取版本号
  {$IFDEF VER150}
  // Delphi 7 的老办法
  Result := 'Delphi 7 Build';
  {$ELSE}
  // 新版本用文件版本信息
  Result := 'Modern Build';
  {$ENDIF}
end;

end.

这个代码在 Delphi 7 里编译,输出“Delphi 7 Build”;在 Delphi 2007 里编译,输出“Modern Build”。听着挺聪明是吧?但是你要是用现代 IDE 里的静态分析工具扫描这段代码,它只认当前编译环境的宏定义,另一个分支的代码完全看不到。所以,你在 IDE 里看到的代码跟实际编译出的行为可能根本不一样。这要是出了问题,排查起来就非常烧脑。

三、现代工具与老语法的“兼容补丁”

3.1 用 Free Pascal 和 Lazarus 当代替品

Free Pascal 是一个开源的 Pascal 编译器,语法兼容性做得不错。Lazarus 是配套的 IDE,界面跟 Delphi 老版本很像。这两个东西最大的好处是跨平台,还能识别大部分老 Delphi 代码。但是,兼容度不是百分百。比如 Windows API 相关的调用,或者基于 COM 的代码,迁移过去就需要改很多。

比如下面这段在 Delphi 里很常见的代码:

// 技术栈: Lazarus (Free Pascal)

unit MainFormUnit;

{$mode objfpc}{$H+}

interface

uses
  Classes, Forms, StdCtrls;

type
  TMainForm = class(TForm)
    BtnGo: TButton;
    LblInfo: TLabel;
    procedure BtnGoClick(Sender: TObject);
  end;

var
  MainForm: TMainForm;

implementation

{$R *.lfm}

procedure TMainForm.BtnGoClick(Sender: TObject);
begin
  // 在界面上显示一段话
  LblInfo.Caption := 'Hello, Lazarus!';
end;

end.

注意这个 {$mode objfpc} 指令,它告诉 Free Pascal 用 Object Pascal 模式,这样 class 语法跟 Delphi 差不多。但是,Lazarus 的窗体文件是 .lfm,不是 Delphi 的 .dfm。你要是直接用 Lazarus 打开 .dfm 文件,它能帮你转换,但转换过来后,窗体上有些控件属性可能会丢失。另外,老代码里用了很多 Delphi 特有的组件,比如 TADOConnection、TClientDataSet,Lazarus 里根本没这些,你得用 SQLdb 或者其他组件重新实现一遍,工作量巨大。

3.2 用代码转换工具辅助迁移

有人专门写了代码转换工具,比如 Pascal Analyzer 或者自带的正则批量替换。但注意,这些工具只能做机械性的转换,处理不了“含义”的变化。举个例子:

// 技术栈: Delphi 5

function GetUserName: string;
begin
  // 这里调用了 Windows API
  // 原代码是 GetWindowsDir + '\user.ini'
  Result := GetWindowsDir + '\user.ini';
end;

上面这段代码里 GetWindowsDir 是个自定义函数,返回 string,在 Delphi 5 里没问题。你要是想把它转成 C#,第一个要搞清楚 string 在 Delphi 5 里是 AnsiString,按单字节处理;而 C# 的 string 是 UTF-16。如果用户目录里有中文,老代码读出来的路径会乱码,新代码不会。这种语义上的坑,转换工具根本管不了。

四、维护技巧和注意事项

4.1 在动手改老界面之前,先写一套自动化编译脚本

老项目最怕的就是改了代码,结果别的模块被连累。找个空闲的服务器装好老版 Delphi,然后写个批处理,每天定时拉取最新代码,编译一次,生成编译日志。至少你能知道,哪个文件在你改之前就已经坏了。

下面是一段简单的编译脚本:

# 技术栈: Windows 批处理 + Delphi 命令行编译器

@echo off
rem 指定 Delphi 7 的编译器路径
set D7_COMPILER="C:\Program Files\Borland\Delphi7\Bin\dcc32.exe"
rem 你的项目文件
set PROJECT="D:\LegacyProject\Main.dpr"
rem 输出日志
set LOG_FILE="D:\BuildLog\build_%date:~0,4%%date:~5,2%%date:~8,2%.log"

echo Start build ... >> %LOG_FILE%
%D7_COMPILER% %PROJECT% >> %LOG_FILE% 2>&1

if %errorlevel% equ 0 (
    echo Build SUCCESS at %time% >> %LOG_FILE%
) else (
    echo Build FAILED with code %errorlevel% at %time% >> %LOG_FILE%
)

这段批处理没什么高深的技术,每天定时跑一次,你早上到公司先看日志。如果发现昨天有人提交的代码把编译搞挂了,你马上就能定位到人。记住,改老代码有时候最可怕的不是改出 bug,而是你不知道别人也改了同一个文件。

4.2 遇到乱码,先别急着改成 UTF-8

老项目的源码文件是原始编码,比如 ANSI 或者 GB2312。你在新电脑上用 VS Code 打开一看,注释全是乱码。这时候千万不要顺手 “以 UTF-8 重新打开” 然后保存,否则整个文件的非 ASCII 字符都会变,重新用 Delphi 编译就全是错。正确做法是先用老的 IDE 把源文件另存为 UTF-8 格式(有的版本支持),或者用专门的编码转换工具批量转。但转完以后,老的编译器可能不认 UTF-8 源文件,所以你还得在文件头部加一条指令:

// 技术栈: Delphi 2009+(支持 UTF-8 源文件)

unit MyUnit;

interface
  // 告诉编译器这个文件是 UTF-8 编码
  {$IFDEF Unicode}
    // 这个位置其实不用写什么
  {$ENDIF}
implementation

end.

但是,如果你的项目还是 Delphi 7,那就别折腾 UTF-8 了,老老实实在老环境里保持原始编码吧。想要新工具能看乱码,你可以在 VS Code 里设置 “文件 → 首选项 → 设置”,给 .pas 文件指定编码为 gbk 或 windows-1252,这样只是预览,不改动文件本身,安全得多。

4.3 把老模块隔离开,新老并存

老项目不可能一下子全重写,最稳妥的策略是“封装老逻辑,提供新接口”。比如,老代码里有些核心计算函数,写在 BusinessLogic.pas 里。你可以在新的服务层里写个适配器,通过命令行或者 HTTP 调用老模块编译出来的 EXE。这样做的好处是,老代码的编译环境不用动,新代码可以享受现代工具链的便利。

看一个简单的适配思路:

// 技术栈: Delphi 2007 写的老计算模块

function CalcPrice(const BasePrice, Discount: Double): Double;
begin
  // 老办法计算
  Result := BasePrice * (1 - Discount);
end;

你可以做一个命令行工具,把参数传进去,拿返回值。然后在新的 Node.js 服务里调用它。

// 技术栈: Node.js (调用老 Delphi 编译的 EXE)

const { execFile } = require('child_process');

// 调用老程序计算价格
execFile(
  'C:\\Legacy\\CalcEngine.exe',
  ['198.5', '0.2'],
  (error, stdout, stderr) => {
    if (error) {
      console.error('老程序执行失败', error);
      return;
    }
    // 老程序输出 "158.8"
    console.log('计算结果:', stdout.trim());
  }
);

当然,这样做性能会差,但比提心吊胆地动老代码强多了。你不需要懂老代码的内部逻辑,只要保证输入输出对齐就行。

4.4 写注释,用“为什么”而不是“是什么”

老项目里有一堆这种注释:

// 技术栈: Delphi 6

procedure TForm1.Button1Click(Sender: TObject);
var
  Data: Integer;
begin
  Data := StrToInt(Edit1.Text);   // 把文本转成数字
  // 乘以 2
  Data := Data * 2;
  Label1.Caption := IntToStr(Data);  // 显示结果
end;

这种注释对维护一点帮助都没有。看代码都能看出来。真正需要记录的是:为什么这里要乘以2?是业务要求还是权宜之计?我记得有个老项目里有一段:

// 技术栈: Delphi 2006

Value := Value + 1; // 加1是为了避开 SQL 保留字,后来才知道是 DBO 的 bug,但这个逻辑别删

这条注释写得就很有用。如果你接手这样的代码,千万遵守原有的约定,别随手把 +1 当成多余逻辑去掉。很多线上事故就是这么来的。

五、实际维护场景复盘

5.1 场景:老 Delphi 程序要支持新打印机

某工厂的老程序是用 Delphi 7 写的,负责打印产品标签。原来用的针式打印机,走 ESC/P 指令,程序里全是 WriteLn(Printer, #27 + 'P') 这种代码。现在换成了热敏打印机,接口变成了 TCP/IP,跟 USB 直连完全不同。

这时候你不可能去改打印驱动的调用方式,因为老程序里所有打印逻辑都是直接操作 LPT1 端口的。你能做的是,写一个小工具,监听 TCP 端口,接收老程序发来的原始打印指令,然后转换,发给新打印机。看伪代码:

# 技术栈: Python(模拟转换服务,不是生产代码)
# 监听 9100 端口,接收老程序传来的 ESC/P 指令
import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 9100))
server.listen(1)

while True:
    conn, addr = server.accept()
    data = conn.recv(4096)
    # 收到的是 ESC/P 指令流
    # 去掉初始化指令,只保留文本内容
    text = data.replace(b'\x1b@', b'').replace(b'\x1bP', b'')
    # 你的新打印机可能接受简单的纯文本 + 换行
    conn.sendall(b'print:' + text + b'\n')
    conn.close()

当然,实际会比这复杂,但思路就是不用改 PChar 操作,不用碰那句 Printer.BeginDoc。只要把老代码的输出端想象成一个普通的“字节流生成器”,新老系统之间的衔接就清晰了。

5.2 场景:IDE 提示消失,只能靠保守的搜索

在老 Delphi 的 IDE 里,如果代码写的太奇葩,比如用了 with 语句嵌套,IDE 的分析器会彻底罢工。比如:

// 技术栈: Delphi 6

procedure TForm1.LoadData;
begin
  with Query1 do
  begin
    Close;
    with SQL do
    begin
      Clear;
      Add('SELECT * FROM orders');
      Add('WHERE amount > :amt');
    end;
    ParamByName('amt').AsFloat := 1000.0;
    Open;
  end;
end;

这段代码在 Delphi 6 里编译运行没问题,但你在新版 IDE 里用代码折叠、重命名等功能,全都失效。因为 with 会让 IDE 搞不清 Close 到底是谁的方法。更搞笑的是,如果你自己写了个过程也叫 Close,那么 IDE 也可能找错目标。

所以,对于这种老代码,建议先用工具把 with 语句全部展开,转成明确的变量引用,这样 IDE 才能正常分析。但展开的时候要格外小心,尤其是连续 with 的情况下,变量优先级容易搞混。

六、面对老代码的心态

别指望老项目能适应现代工具。工具是为人服务的,老代码能跑就是胜利。你要做的不是追求完美重构,而是在老代码的语法规则和现代工具之间搭一座桥。这座桥可能不漂亮,但好用就行。

维护老 Pascal 项目,大部分时间是在跟“不确定性”作斗争。你改了一个变量,不知道哪里还会引用它;你加了一个字段,不知道有没有序列化逻辑会漏掉。现代 IDE 的优点你很难用上,但老代码也有个特点:它确实能跑,而且非常稳定。那是经过十几年生产环境打磨的稳定,比新写的程序可靠得多。

所以,当你面对 begin 和 end 的时候,别着急,静下心来,一行一行读。保不齐你会读到曾经的程序员留下的智慧,也保不齐你会发现一个埋了十年的定时炸弹。

七、文章总结

老 Pascal 项目维护的难点,表面上是语法问题,实际上是“上下文鸿沟”。老代码生长在特定的编译器版本、特定的操作系统时代、特定的需求背景下,它的一切都是为了当时的环境服务的。现在工具变了,环境变了,那颗老心脏还得持续跳动,必然会出现排异反应。

我们能做的,是理解语法规则的合理内核,把它们封装起来,隔离起来,而不是试图让老代码变成现代风格。有条件就用 Free Pascal 做兼容层,没条件就写个批处理跑自动化编译,甚至用微服务的方式把老功能包装成独立进程。关键不在于你的技术有多新,而在于你愿不愿意去了解那段老代码当初为什么这么写。

工具永远是可以选择的,但业务逻辑一旦弄错了,损失的可不只是时间。所以,好好善待老 Pascal,它也是一段真实的历史,值得耐心对待。