如何衡量 C 程序翻译为 Rust 后的结构相似度?
建议先分别解析 C 和 Rust,将需要比较的结构转换成共同表示,再从控制流、函数拆分、调用关系、数据结构和错误处理五个维度评分。
只比较字符串、原始 AST 或编译后的控制流图都不够。你既关心执行路径,也关心源码的表达方式,需要分开保留这些信息。
先确定什么变化算结构变化
可以先建立一份比较规则表。下面是一种适合当前需求的起点,具体权重由你的评估目标决定。
| 比较维度 | 需要保留的信息 | 应记录的变化 |
|---|---|---|
| 控制流 | 循环类型、分支、嵌套、continue、break、return | for 改为 iterator chain,分支被合并 |
| 函数拆分 | 函数边界、对应关系 | 一对一、拆分、合并、内联 |
| 调用图 | 调用者与被调用者之间的边 | 调用链缩短、新增中间函数、边被删除 |
| 数据结构 | struct 边界、字段、顺序、嵌套关系 | 字段丢失、扁平化、拆分、重新嵌套 |
| 错误处理 | 检查条件、提前返回、传播形式 | 显式检查改为 ?,返回码改为 Result |
例如,变量改名通常可以忽略,但应保留 for 改为 .for_each() 这样的差异。如果把 for 和 .for_each() 都归一化为同一个“循环”节点,就无法回答你最关心的问题。
用两层表示比较控制流
第一层:保留源码表达方式的结构树
从两种语言的 AST 中提取共同节点,同时保留语言相关属性。例如:
Function
Loop(kind=for)
Branch
Continue
Call
Return
对于 Rust 的 iterator chain,可以记录为:
Function
IteratorChain
Filter
ForEach
Call
Return
这两棵树可以用同一套规则比较,即使遍历效果可能相似,也不会被视为完全相同。
可以归一化空白、注释、局部变量名,以及规则允许忽略的括号差异。循环种类、语句顺序、嵌套关系、提前退出和错误传播形式则应保留。
第二层:控制流图 CFG
CFG 用来辅助比较分支连接、回边和退出路径。它有助于判断路径组织是否相似,但通常无法单独判断源码是否保留了 for 或显式错误检查。
CFG 的抽取阶段也会影响结果。源码级表示与经过 desugaring、内联或优化后的表示,可能具有不同的图结构。比较时应记录抽取阶段并固定处理规则,以免把编译处理造成的变化误记为翻译造成的变化。
对于当前目标,可以将源码结构树作为主要依据,CFG 作为补充指标。
先匹配函数,再比较调用图
不要直接按函数名比较两个程序。翻译可能改名,也可能引入辅助函数。
优先使用翻译过程提供的源码位置映射或函数映射。如果没有这些信息,可以结合名称、参数数量、函数体结构和调用邻居生成候选对应关系,并为不确定的匹配保留置信度。
映射需要允许以下情况:
- 一对一:一个 C 函数对应一个 Rust 函数。
- 一对多:原函数被拆成多个函数。
- 多对一:多个原函数被合并。
- 无独立对应函数:可能被删除、内联,或尚未成功匹配,需要进一步区分。
函数拆分本身也是评估结果,强制一对一匹配会把它掩盖。可以分别报告一对一保留比例、拆分与合并数量、未匹配比例。
完成映射后,再把调用图节点替换为共同的函数标识,比较调用边。若把拆分出的多个函数收缩成一个逻辑节点,应同时保留收缩前的结果,说明原始结构发生了什么变化。
用可解释的指标评分
结构树:树编辑距离
可以用树编辑距离衡量节点插入、删除和替换的代价。建议按你的目标设置代价,例如将“变量改名”设为零代价,将“for 改为 iterator chain”设为非零代价。
一种归一化方式是:
[ S_{tree}=1-\frac{d(T_C,T_R)}{D(T_C)+I(T_R)} ]
其中,(d) 是最小编辑代价,(D) 是删除整棵 C 结构树的代价,(I) 是插入整棵 Rust 结构树的代价。要求代价非负,并允许通过整树删除、插入完成转换;分母为零的情况需要单独约定。
这个分数描述指定规则下的结构接近程度。编辑代价改变,分数的含义也会改变,因此规则必须随结果一起保存。
调用图:匹配后的边 precision、recall 和 F1
设原程序调用边集合为 (E_C),翻译后为 (E_R),两者已经使用共同的函数标识,则:
[ P=\frac{|E_C\cap E_R|}{|E_R|},\qquad R=\frac{|E_C\cap E_R|}{|E_C|} ]
[ F1=\frac{2|E_C\cap E_R|}{|E_C|+|E_R|} ]
下面的 Python 示例只演示调用边评分,不包含解析和函数匹配:
def call_edge_f1(c_edges, rust_edges):
c_edges = set(c_edges)
rust_edges = set(rust_edges)
total = len(c_edges) + len(rust_edges)
if total == 0:
return None # 两边均无调用边,此指标不提供区分信息
return 2 * len(c_edges & rust_edges) / total
c_edges = {("A", "B"), ("B", "C")}
rust_edges = {("A", "B"), ("A", "C")}
print(call_edge_f1(c_edges, rust_edges)) # 0.5
该指标比较的是直接调用边,不衡量所有路径特征。如果特别关注 A → B → C 这样的调用链,还可以比较固定长度路径或各节点的调用邻居。
函数指针、动态分派或外部调用可能无法被静态分析完整解析。应报告未解析调用的数量和处理方式,避免把“没解析出来”直接算成“翻译后被删除”。
数据结构和错误处理需要单独分析
对于 struct,可以先建立类型与字段映射,再比较字段保留率、字段顺序、嵌套路径和结构拆分情况。字段改名不一定意味着字段丢失,字段数量相同也不意味着结构保留。
还需要区分源码中的组织方式与实际内存布局。若关心字段和嵌套是否保留,比较类型结构即可;若关心偏移、对齐和 ABI,则需要额外固定目标平台与布局约定,不能根据源码外观推断。
错误处理应记录具体模式。下面的代码仅用于说明比较方式,假设 op() 的两种接口表达相应的失败条件:
int f(void) {
int ret = op();
if (ret < 0) {
return -1;
}
return 0;
}
fn f() -> Result<(), Error> {
op()?;
Ok(())
}
结构报告应记录“显式条件检查与提前返回被改为 ? 传播”,而不是简单给出“相同”或“不同”。这段示例不证明两种实现语义等价;返回值含义和错误映射仍需另行核对。
最终报告应能定位差异
建议先输出五个维度的结果,再按需要计算加权总分:
[ S=\sum_i w_iS_i,\qquad w_i\geq0,\quad\sum_iw_i=1 ]
权重没有通用标准。当前需求强调源码形态,可以提高源码结构树和函数边界的权重。没有可比较内容的维度应标为不适用,并明确剩余权重如何调整。
实际落地可以按以下顺序进行:
- 固定需要保留和允许忽略的结构特征。
- 解析两种语言,提取结构树、函数、调用边和类型结构。
- 建立函数与类型映射,报告匹配覆盖率。
- 分维度评分,输出新增、删除和改写的位置。
- 用少量人工标注样例校准规则,检查指标是否符合你的判断。
初期可以先实现函数对应关系、循环与分支结构、调用边三个维度。每个分数都应附上具体差异,例如“此处 for 被改为 iterator chain”或“此调用链新增一个辅助函数”,以帮助判断翻译保留了哪些结构。结构得分的高低本身不能证明语义正确,也不代表 Rust 代码质量的高低。
备注:内容仅供参考。
