Fortran转C++:f2cpp迁移遗留数值代码的工程实践

发布时间:2026/10/27 12:16:25
Fortran转C++:f2cpp迁移遗留数值代码的工程实践

简介:f2cpp 是一款面向科学计算开发者与代码迁移需求者的开源转换工具,以 Python 脚本实现,可将 Fortran 77 程序翻译成可读性更好的 C++ 源码,帮助科研人员把沉淀多年的老代码库平滑带入现代 C++ 工程环境。与早期 f2c 相比,它生成的结果在结构保留与命名风格上更接近人工书写,便于二次调整与团队维护;而 common blocks、数组维度、子程序接口等 Fortran 特有写法在转换后仍需针对性适配,这正是实际使用时的关键关注点。压缩包共 11 个文件、约 48KB,以 7 个 Python 脚本为主体,涵盖正则匹配、IO 替换、格式化、c2cpp 等模块,另配 2 份 doc 说明文档、1 个 shell 启动脚本和 1 个 .i 文件,体量轻便、依赖较少,适合直接研读脚本逻辑或嵌入自有工具链。已有 978 人学习下载,可供需要理解转换实现、复用脚本框架或推进遗留代码现代化改造的工程师参考。

1. 从一批老 Fortran 代码说起:f2cpp 开源转换器到底能替你省多少事

接手一批上世纪写成的 Fortran 数值代码,编译器工具链被锁死在一套很老的构建环境里,想把它挪进 C++ 工程又不敢重写——这是促使我认真去找 f2cpp 这类开源转换器的典型场景。f2cpp 做的事情很实际:把 Fortran 源码按语法结构翻译成能编译的 C++ 源码,不靠人工逐行誊抄,也不要求你先把算法读懂。它适合三类人:手里有 F77/F90 遗留模块要并入 C++ 主干的;想给老算法套一层现代构建与单元测试的;以及必须长期维护、每次改动都要能 diff 出差异的。不适合指望它一键产出工业级 C++ 设计的人,产物更像“可编译的等价物”,而不是“漂亮的工程代码”。

2. 拆开 f2cpp 的翻译链路:从行级切分到 C++ 语义落地

看清 f2cpp 内部流程以后,很多“翻译出来跑不对”的问题其实在第一步就注定了。这类转换器基本都走三段式:前端做行格式判定与词法切分,中端建符号表、把 IMPLICIT 规则显式化,后端做类型映射、下标重排和 IO 语句改写。三段里任何一段的判断偏了,最后都会变成数值不对或者不报错地越界,而编译器不会给你任何提示。

我的习惯是:不要拿到源码直接上整包转换,先挑一个只有几十行、输出全是标量的子程序试通,确认三段都对,再把边界往外扩。下面按这四段拆。

2.1 前端:固定格式与自由格式的行级判定

Fortran 最反直觉的一点是“第几列有讲究”。固定格式里第 1 列是注释标记(C或*),第 1 到 5 列是语句标号,第 6 列是续行标记,真正的代码从第 7 列开始。很多在上世纪写成的文件后缀叫.f,但内容其实是自由格式;也有反过来,后缀.f90里却藏着固定格式的续行。f2cpp 默认会按后缀猜格式,猜错就直接把整行当成注释丢掉,产物里少一段代码,编译还能过,结果就错了。

判断办法很简单,转换前先扫一遍文件:

# 统计每行首字符分布,快速识别是不是固定格式 awk '{ c = substr($0, 1, 1); if (c == "C" || c == "c" || c == "*") cmt++; else if (substr($0, 6, 1) != " " && substr($0, 6, 1) != "0") cont++; else code++; } END { printf "comment=%d continue=%d code=%d\n", cmt, cont, code }' legacy.f

这段 awk 只做一件事:把行按“首字符像注释”“第 6 列非空像续行”“其余当代码”三分类计数。如果comment数量远超文件行数的合理比例,说明这个文件八成是自由格式被误当固定格式,或者相反。确认以后在转换命令里显式指定格式参数,不要依赖后缀猜测——这是我在这一层唯一强制要求的动作。

提示:固定格式里制表符(Tab)会破坏列对齐,转换前统一expand -t 8展开成空格,比事后调参数省事得多。

2.2 中端:符号表与 IMPLICIT 规则必须先显式化

Fortran 有个老规矩:不写IMPLICIT NONE,以 I 到 N 开头的变量名默认是整型,其余默认是实型。这个规则靠符号表实现,而 C++ 必须每个名字都有明确类型。f2cpp 的中端就是在建符号表的过程中,把这条隐式规则“算出来”并写成显式声明。

问题在于,隐式规则一旦和IMPLICIT REAL*8 (A-H,O-Z)这类语句叠加,同一个变量在不同子程序里可能是不同精度。我一般会在转换前做一次全局体检:

# 找出所有没有 IMPLICIT NONE 的编译单元,逐个人工确认 grep -rniE '^\s*(program|subroutine|function|block data)\b' --include='*.f*' . \ | while IFS=: read -r file line _; do # 取该单元起始行后 40 行内是否出现 IMPLICIT NONE if ! sed -n "${line},$((line+40))p" "$file" | grep -qi 'implicit\s*none'; then echo "NO_IMPLICIT_NONE $file:$line" fi done

这个脚本的判定范围是“单元起始行往后 40 行”,因为声明区通常就在开头。参数上没什么可调的,40 这个数按你代码的声明区长度改即可。命中的文件不要直接转换,先补IMPLICIT NONE再编译一遍原 Fortran,能过再转——先让老代码自己把类型说清楚,比让转换器去猜稳得多。

2.3 后端:列主序到行主序的那次“隐形转置”

Fortran 多维数组按列优先存储,C++ 原生按行优先。f2cpp 的后端有两种策略:一是保持列优先,把多维数组降成一维并手写下标换算;二是真的转成行优先布局。两种都能跑,但混用就出事——尤其是你把转换产物和手写的 C++ 代码链在一起、互相传二维数组的时候。

我一般选第一种,因为它和现有的 Fortran 数学库、BLAS 接口能对上。换算公式是固定的:

// Fortran: REAL*8 A(10, 20),A(I, J) 的偏移是 (I-1) + (J-1)*10 // 保持列优先:存成一维 a_[200],按下标函数访问 static constexpr int LDA = 10; static constexpr int LDB = 20; static inline double& A(int i, int j) { return a_[static_cast<std::size_t>(i - 1) + static_cast<std::size_t>(j - 1) * LDA]; } double a_[LDA * LDB];

LDA是第 1 维长度,也是列优先下的“行跨度”,写成常量而不是散落的魔法数字,是为了后续改维度时只改一处。i - 1和j - 1是 1 基到 0 基的换算,这一步 f2cpp 会自动做,但如果你手工补了访问函数,务必自己对齐,别一边自动一边手工。

注意:一旦决定保持列优先,就不要在任何地方用a_[i * LDB + j]的行优先写法去访问同一块内存,两种写法在方阵上看起来很接近,在非方阵上会直接算错。

2.4 构建 f2cpp 并通过第一个冒烟用例

前面三步都是判断,这一步才真正动手。从源码构建时我习惯装到用户目录,避免污染系统环境,也方便同时留两份不同快照做对比。

# 构建并安装到本地前缀;CMAKE_BUILD_TYPE 用 Release,翻译器本身性能影响不大但省时间 git clone <f2cpp 源码地址> f2cpp cd f2cpp cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX="$HOME/.local/f2cpp" cmake --build build -j"$(nproc)" cmake --install build # 装完先看参数表,不同快照的参数名会有微调,别照抄别人的命令 "$HOME/.local/f2cpp/bin/f2cpp" --help | head -40

-DCMAKE_INSTALL_PREFIX决定cmake --install的落点,装完可执行文件在bin/下。--help那一步不要跳过,我踩过的最冤的一次就是照着一份旧笔记抄参数,名字早改了,命令静默忽略未知选项,产物看着正常但少了关键处理。

冒烟用例写成固定格式,故意保留标号和续行,逼转换器把前端走全:

C SMOKE.F —— 固定格式:第 1 列注释,第 1-5 列标号,第 7 列起代码 PROGRAM SMOKE IMPLICIT NONE INTEGER I REAL*8 S S = 0.0D0 DO 10 I = 1, 100 S = S + DBLE(I) 10 CONTINUE WRITE(*,*) 'SUM =', S END

转换命令带上语言标准和输出路径:

f2cpp smoke.f -o smoke.cpp --std=c++17 g++ -std=c++17 -O2 smoke.cpp -o smoke && ./smoke # 期望输出:SUM = 5050.00000000000

--std=c++17是让产物用现代标准(结构化绑定、constexpr之类会更干净);-o指定产物路径,不指定时默认同目录同名.cpp。跑出来的值和5050对上,说明前端没丢行、类型映射没错、DO 循环的边界(Fortran 的1,100是闭区间,C++ 要写成i <= 100)也对上了。这一步不过,后面所有工作都白搭。

3. 转一个真实的数值模块:参数怎么设、产物怎么接、结果怎么验

冒烟用例只能证明工具活着,真正决定成败的是参数。财务类、仿真类代码对精度和 IO 格式极其敏感,一个参数选错,产物编译通过、单元测试也能过,但业务侧对账就是差最后几位。这一章按“预处理 → 参数 → 集成 → 验证”的顺序走一遍。

3.1 输入预处理:统一编码、大小写与续行

转换前先做一次机械预处理,成本很低但收益很大。三件事:文件编码统一成 UTF-8、去掉行尾多余空白、把制表符展开成空格。Fortran 对大小写不敏感,f2cpp 会把标识符转成小写,如果你手写的 C++ 里混着大写引用,链接期才会报未定义。

mkdir -p prep for f in src/*.f src/*.f90; do base=$(basename "$f") # expand 展开 Tab,sed 去行尾空白,iconv 统一编码 expand -t 8 "$f" \ | sed -e 's/[[:space:]]*$//' \ | iconv -f GB18030 -t UTF-8 2>/dev/null > "prep/$base" \ || cp "$f" "prep/$base" echo "prepped $base" done

iconv那一步加2>/dev/null和||兜底,是因为历史代码里常混着已经是 UTF-8 的文件,转换失败时直接原样拷过去更安全,别因为一个编码转换让整批文件丢失。expand -t 8的 8 要和编辑器里看到的 Tab 宽度一致,否则固定格式的列判定还是会偏。

3.2 参数对照表:能照抄的那几行

不同快照的参数名会有微调,装完用--help对一遍再抄。下面这张表是我在数值模块上固定会检查的项:

参数作用建议取值出错时的表现
--std产物使用的 C++ 标准c++17低于 14 时constexpr、inline变量写法会退化,产物变臃肿
--fixed-form/--free-form强制指定源文件格式按实际内容,不按后缀续行被当注释丢弃,产物少语句但不报错
--real-kind实型默认字节数与老代码REAL*8一致单双精度混用,累加结果末位漂移
--integer-kind整型默认字节数4(int32_t)用int在部分平台是 8 字节,与COMMON布局错位
--array-origin数组下标的基1(保持 Fortran 语义)设成 0 时手工补的访问函数全部错位一格
--common-modeCOMMON块的映射方式结构体聚合映射成散落全局变量,链接期重名或布局混乱
--io-formatWRITE/FORMAT的处理保留格式串退化成默认流输出,对账文件字段宽度全变

最容易被忽略的是--integer-kind。Fortran 的INTEGER在绝大多数实现上是 4 字节,而 C++ 的int在部分平台上是 8 字节——单看一个变量没影响,一旦这个变量进了COMMON块或者被当指针做偏移,整个块后面所有成员全部错位,而编译器一声不吭。

3.3 产物怎么接进 CMake:把翻译做成构建的一步

我不建议把转换产物提交进仓库——老代码一改,产物就过期,最后没人知道哪个版本是真的。正确做法是让 CMake 在构建时调 f2cpp,把.f当源文件。

# 把 Fortran 源码声明成“需要翻译的源”,构建时生成 .cpp 到 build 目录 set(F2CPP_BIN "$ENV{HOME}/.local/f2cpp/bin/f2cpp") set(GEN_DIR "${CMAKE_BINARY_DIR}/generated") file(MAKE_DIRECTORY "${GEN_DIR}") set(FORTRAN_SRCS src/solver.f src/matvec.f) set(GEN_SRCS "") foreach(f ${FORTRAN_SRCS}) get_filename_component(stem "${f}" NAME_WE) set(out "${GEN_DIR}/${stem}.cpp") add_custom_command( OUTPUT "${out}" COMMAND "${F2CPP_BIN}" "${CMAKE_CURRENT_SOURCE_DIR}/${f}" -o "${out}" --std=c++17 --integer-kind=4 --real-kind=8 DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/${f}" COMMENT "f2cpp: ${f} -> ${stem}.cpp" VERBATIM) list(APPEND GEN_SRCS "${out}") endforeach() add_library(legacy_num STATIC ${GEN_SRCS}) target_include_directories(legacy_num PUBLIC "${GEN_DIR}")

add_custom_command的DEPENDS必须写上原始.f文件,否则改了 Fortran 源码 CMake 不会重新翻译,你会对着旧产物调试半天。VERBATIM是让参数里的路径空格被正确转义,路径带空格时不加会拆参数。--integer-kind=4 --real-kind=8在这里写成硬编码而不是靠默认值,是为了让不同机器上的构建结果可复现。

3.4 数值一致性验证:新旧两版都吐同一份文本再比

验证不要靠肉眼看输出,也不要靠单元测试的断言——老代码本来就没有测试。我的做法是:让原 Fortran 程序和转换后的 C++ 程序输出同一份“变量名 + 值”的文本,然后用脚本逐项比对。

import sys import numpy as np def load(path): # 约定格式:每行 "变量名 值",# 开头是注释,空白分隔 names, vals = [], [] with open(path, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line or line.startswith("#"): continue key, val = line.split() names.append(key) vals.append(float(val)) return names, np.asarray(vals, dtype=np.float64) ref_names, ref = load(sys.argv[1]) # 老 Fortran 程序的输出 new_names, new = load(sys.argv[2]) # 转换后 C++ 程序的输出 assert ref_names == new_names, "变量顺序不一致,先对齐再比" tol = float(sys.argv[3]) if len(sys.argv) > 3 else 1e-10 # 混合误差:绝对值小于 1 时按绝对差,大于 1 时按相对差,避免大数掩盖小数 diff = np.abs(ref - new) / np.maximum(np.abs(ref), 1.0) bad = np.where(diff > tol)[0] for idx in bad: print(f"{ref_names[idx]}: 旧={ref[idx]:.17g} 新={new[idx]:.17g} 相对差={diff[idx]:.3e}") sys.exit(1 if bad.size else 0)

tol的默认值1e-10是给双精度的;如果你的模块里有迭代求解、累加次数上万,末位漂移是正常的,这时候把tol放宽到1e-8,但不要更宽——放宽到1e-6就分不清“浮点误差”和“真的算错了”。np.maximum(np.abs(ref), 1.0)这个分母是关键,纯相对误差在接近 0 的值上会炸出巨大的比值,纯绝对误差又管不住大数量级,混合写法两种都能覆盖。退出码返回 1 是为了接进 CI,比对失败直接红灯。

4. 三类最难受的语义:IMPLICIT、COMMON 与 GOTO 的落地写法

前面讲的是流程,这一章讲的是“流程都对、结果还是错”的那部分。这三类语义在 Fortran 里天经地义,在 C++ 里没有直接对应物,转换器只能做近似,剩下的边界得你自己兜。

4.1 IMPLICIT 与 REAL*8:一字节之差算错三天

老代码里常见的写法是IMPLICIT REAL*8 (A-H,O-Z),意思是 A 到 H、O 到 Z 开头的名字默认是 8 字节实型,I 到 N 开头的是整型。这个规则本身没问题,问题出在“部分文件写了、部分没写”。当你把两个子程序一起转换、再链接到同一个可执行文件里,一个按单精度、一个按双精度,接口参数对不上,链接器不会报错(C++ 里float和double的符号修饰不同,但如果走的是 C 风格的外部符号就可能偷偷对上)。

处理办法是转换前统一“精度口径”,写成显式的:

C 把隐式规则显式化,转换器就不需要猜 IMPLICIT NONE INTEGER, PARAMETER :: DP = KIND(1.0D0) REAL(DP) :: alpha, beta INTEGER :: iter

KIND(1.0D0)取的是双精度的 kind 值,比硬编码REAL*8更可移植。显式化以后再转,产物里的类型就是确定的一一对应,不会再出现“这个alpha到底是float还是double”的扯皮。

注意:如果老代码里存在同名变量在不同单元里精度不同(确实有),不要试图让转换器去统一,那是业务 bug,先修老代码。

4.2 COMMON 与 EQUIVALENCE:布局顺序就是契约

COMMON块在 Fortran 里就是一块共享内存,各编译单元按声明顺序对齐。转换到 C++ 有两种做法:拆成一组全局变量,或者聚合成一个结构体。我强烈建议后者,因为结构体天然保证了“顺序 + 对齐”这两件事,而且可以一次性把sizeof打出来核对。

C 声明顺序即布局顺序:A(10) 占 80 字节,B 占 8 字节,N 占 4 字节 COMMON /BLK1/ A, B, N REAL*8 A(10), B INTEGER N

对应产物应该长这样:

#include <cstdint> // 对应 COMMON /BLK1/ A, B, N:成员顺序不能改,改了就整体错位 struct blk1_t { double a[10]; // 80 字节,对应 Fortran 的 A(1..10) double b; // 8 字节 int32_t n; // 4 字节,用 int32_t 锁死,别用 int }; static_assert(sizeof(blk1_t) == 92, "BLK1 布局与 Fortran 侧不一致"); static blk1_t blk1; // 所有引用 BLK1 的翻译单元共享这一份

static_assert那一行是后悔药:不同编译器对double的对齐要求可能不同,加了断言以后,一旦布局变了编译期就炸,而不是运行到一半数据串味。int32_t而不是int,是为了在 LP64 平台上也不会悄悄变成 8 字节。EQUIVALENCE更麻烦,它让两组变量共享同一段内存,C++ 里只能用union近似,且必须确认两组类型的尺寸完全一致——尺寸不一致时 f2cpp 通常会报错退出,别绕过这个报错。

4.3 计算型 GOTO 与算术 IF:翻译成 switch 才可读

计算型GO TO (100, 200, 300), ICODE的意思是按ICODE的值跳到第 N 个标号。转换器一般会把它翻成一串if或者switch,后者更干净:

// 对应 GO TO (100, 200, 300), ICODE switch (icode) { case 1: goto L100; case 2: goto L200; case 3: goto L300; default: /* Fortran 未定义行为,这里显式兜底 */ std::abort(); }

default分支一定要写。Fortran 标准里ICODE越界是未定义行为,老代码能跑只是因为“碰巧没越界”;C++ 侧显式abort至少能在测试阶段把问题揪出来。算术 IFIF (X) 10, 20, 30同理,按X < 0、X == 0、X > 0三分支翻译,注意X == 0的浮点比较要写成精确等于——这里恰恰不能加 epsilon,因为原语义就是“严格等于零”。

转换完以后,用grep -n 'goto' generated/*.cpp过一遍,看看有多少goto是转换器生成的、有多少是控制流实在绕不开的。数量在个位数以内通常可以接受,上百个说明这批代码的流程本身就需要重构,先把goto收敛成结构化写法再转,产物可读性会好一个档次。

5. 避坑与排查:五条踩过的记录

5.1 数组越界不报错,只是结果悄悄变了

现象:转换后的 C++ 程序跑完不崩,但和 Fortran 结果差在末几位,且差的位数随输入规模变大。

原因:Fortran 的数组是 1 基,转换器默认做了-1换算,但代码里存在A(I-1)这类手工偏移的写法,转换后再减 1 就整体前移了一格,读写到了相邻变量。C++ 的operator[]不做边界检查,越界读写恰好落在合法内存里,所以不崩。

解决:编译期加-fsanitize=address,undefined跑一遍测试用例,越界会立刻报出来;长期做法是把产物里的裸数组访问包成带assert的访问函数,NDEBUG打开时自动退化,不影响性能。

5.2 单精度常量被提升成双精度

现象:明明是老代码里的REAL变量,转换后算出来的值精度反而变高了,导致和 Fortran 侧的对账文件对不上。

原因:Fortran 里1.0是单精度常量,1.0D0才是双精度。转换器如果统一按双精度处理字面量,表达式会整体被提升,中间结果就变了。

解决:用--real-kind显式锁住默认实型宽度,转换后抽查产物里字面量的后缀(f后缀有没有丢)。对精度敏感的模块,宁可在 Fortran 侧把常量全部写成带D0或E0的显式形式,转换器就不需要做任何推断。

5.3 定长字符串补空格导致比较失败

现象:IF (NAME .EQ. 'ABC')在 Fortran 里成立,转换后永远是假。

原因:Fortran 的CHARACTER*8会自动补空格成'ABC ',且比较时会忽略尾部空格;C++ 的std::string或char[8]不会自动补齐,strcmp也不忽略尾部空格。

解决:转换产物里对定长字符的比较统一走“先rstrip再比”的封装函数,别直接用==。数据入口处也做一次右补空格,保证和 Fortran 侧的存储形态一致。

5.4 格式化 IO 的字段宽度漂移

现象:对账文件能生成,但下游解析报错,说某一列对不齐。

原因:老代码的FORMAT里有明确的字段宽度(比如F10.4、E12.5),转换器如果退化成 C++ 的默认<<输出,宽度和指数位数都会变,E格式的科学计数法位数也和 Fortran 不一样。

解决:保留--io-format的格式化模式,不要图省事关掉;验证阶段专门写一条比对用例,只比文本行、不比数值,宽度差一位就能发现。这类问题不影响计算正确性,但会让整条数据链路在下游断掉。

5.5 SAVE 变量与递归调用

现象:子程序第一次调用结果正确,第二次调用结果错误,且错误值恰好是上次的累积。

原因:Fortran 里带初始值的局部变量隐式具有SAVE属性,跨调用保持状态;转换器如果把它当成普通局部变量放在栈上,每次调用都重新初始化,语义就变了。

解决:转换后搜产物里的static关键字,确认原本带SAVE的变量都被标成了static;反过来,被误标成static的变量在多线程下会变成共享状态,需要加锁或者干脆改成传参。这一条在并行化阶段会集中爆发,早查早省事。

6. 把 f2cpp 接进批处理流水线:一次转一批,失败的不阻塞

单文件转换靠手敲还行,一旦模块上百个,必须做批处理,而且批处理的关键不是什么都能转成功,而是“转失败的能被单独拎出来,不拖住其余的”。我的做法是跑一遍全量,记录三态:翻译失败、翻译成功但编译失败、全通。

#!/usr/bin/env bash set -u SRC_DIR=./prep OUT_DIR=./generated LOG=convert_report.tsv mkdir -p "$OUT_DIR"; : > "$LOG" while IFS= read -r -d '' f; do base=$(basename "$f"); stem="${base%.*}" err="$OUT_DIR/$stem.err" # 第一步:翻译,失败就记 TRANSLATE_FAIL,不进入编译 if f2cpp "$f" -o "$OUT_DIR/$stem.cpp" --std=c++17 \ --integer-kind=4 --real-kind=8 2>"$err"; then # 第二步:只做语法检查,不链接,速度快且能筛掉大部分类型问题 if g++ -std=c++17 -fsyntax-only -I./compat "$OUT_DIR/$stem.cpp" 2>>"$err"; then printf '%s\tOK\t-\n' "$base" >> "$LOG" else printf '%s\tCOMPILE_FAIL\t%s\n' "$base" "$err" >> "$LOG" fi else printf '%s\tTRANSLATE_FAIL\t%s\n' "$base" "$err" >> "$LOG" fi done < <(find "$SRC_DIR" -type f \( -name '*.f' -o -name '*.f90' \) -print0) # 按状态汇总,先攻数量最多的那一类 cut -f2 "$LOG" | sort | uniq -c | sort -rn

find ... -print0配read -d ''是为了扛住文件名里的空格,老代码库的命名什么都可能有。-fsyntax-only只做前端检查不做代码生成,几百个文件的批处理能省掉大量时间,而且这一层已经能筛掉绝大多数翻译问题。compat目录放我手写的兼容层(定长字符串封装、格式化输出辅助),-I把它挂上,产物里对这类调用的引用就能解析。日志写成 TSV 而不是直接打印,是为了后面按列切分汇总——先看哪一类失败最多,集中攻那一类,比逐个文件看报错快得多。

从那以后我每次接老 Fortran 迁 C++ 的活,都强制先跑一遍这个三态报表,再决定是“整批转换”还是“挑核心模块手工重写”。报表里TRANSLATE_FAIL占比超过三成,基本可以判定这批代码的写法已经超出转换器的能力边界,硬转的维护成本会高于重写。希望帮到你。

本文还有配套的精品资源,点击获取