使旧代码适应新现实






4.86/5 (33投票s)
本文描述了将旧式 C/C++ 代码转换为完全托管的 C# 代码的有用技术。这些方法曾用于将经典的 libjpeg 和 libtiff 库移植到 .NET Framework。
目录
引言
在本文中,我将介绍一种可以以最小的努力将 C/C++ 代码转换为 C# 代码的方法。本文提出的原则也适用于其他语言对。我想事先警告您,此方法不适用于移植任何 GUI 相关代码。
这有什么用?例如,我曾用这种方法将著名的 TIFF 库 libtiff 移植到 C#(也移植了 libjpeg)。这使我能够在我自己的程序中重用 libtiff 的许多贡献者以及 .NET Framework 类库的工作。我文章中的代码示例主要取自 libtiff / libjpeg 库。
1. 先决条件
您将需要什么
- 可以“一键”构建的原始代码
- 一组同样可以“一键”运行的测试
- 版本控制系统
- 对重构原则的一些基本理解
“一键”构建和测试运行的要求是为了尽可能加快“修改 - 编译 - 运行测试”的周期。每次这样的周期花费的时间和精力越多,它被执行的次数就越少。这可能导致错误更改的大规模和复杂回滚。
您可以使用任何版本控制系统。我个人使用 Subversion - 您可以选择任何您熟悉的。任何能替代硬盘上文件夹集合的东西都可以。
需要测试以确保代码在任何给定时间都能保留其所有功能。知道代码没有引入任何功能性更改是我的方法与“从头用新语言重写”的方法不同的地方。测试不要求覆盖 100% 的代码,但最好有对代码所有主要功能的测试。测试不应该访问代码的内部,以避免不断重写它们。
这是我用来移植 LibTiff 的东西
- 一组 TIFF 格式的图像
- tiffcp,一个命令行实用程序,用于在不同的压缩方案之间转换 TIFF 图像
- 一组使用 tiffcp 进行转换任务的批处理脚本
- 一组参考输出图像
- 一个对输出图像与参考图像集进行二进制比较的程序
要掌握重构概念,您只需要阅读一本书。Martin Fowler 的《重构:改善现有代码的批评性设计》。如果您还没有读过,请务必阅读。任何程序员都可以通过了解重构原则而受益。您不必读完整本书,从开头读前 130 页就足够了。这是前五章以及第六章的开头,直到“内联方法”。
不用说,您对源语言和目标语言的了解越深入,转换就越容易。请注意,在开始时不需要对原始代码的内部有深入的了解。了解原始代码的作用就足够了,如何做到的更深入的理解会在过程中出现。
2. 迁移过程
该方法的本质是通过一系列简单的小型重构来简化原始代码。您不应尝试一次性更改大量代码并尝试对其进行优化。您应该循序渐进,每次更改周期后都运行测试,并确保保存每次成功的修改。也就是说,做一个小改动 - 测试它。如果一切顺利,则在 VCS 存储库中保存该更改。
迁移过程可以分为 3 个主要阶段
- 用更简单但功能等效的东西替换原始代码中的所有特定于语言的功能。这通常会导致代码运行速度较慢且外观不那么整洁,但在现阶段不必担心。
- 修改已更改的代码,使其可以在新语言中编译。
- 迁移测试,并使新代码的功能与源语言的代码匹配。
完成这些阶段后,您才应该关注代码的速度和美观。
第一阶段是最复杂的。目标是将 C/C++ 代码重构为“纯 C++”代码,其语法尽可能接近 C# 语法。此阶段意味着要摆脱
- 预处理器指令
- goto 语句
- typedef 语句
- 指针算术
- 函数指针
- 自由(非成员)函数
让我们详细介绍这些步骤。
2.1 移除不必要的代码
首先,我们应该清除未使用的代码。例如,就 libtiff 而言,我删除了未用于构建库的 Windows 版本的那些文件。然后,我在剩余的文件中找到 Visual Studio 编译器忽略的所有条件编译指令,并将它们也删除了。下面给出了一些示例
#if defined(__BORLANDC__) || defined(__MINGW32__)
# define XMD_H 1
#endif
#if 0
extern const int jpeg_zigzag_order[];
#endif
在很多情况下,源代码包含未使用的函数。它们也应该被淘汰。
2.2 预处理器和条件编译
通常,条件编译用于创建程序的专用版本。也就是说,某些文件包含 #define
作为编译器指令,而其他文件中的代码则包含在 #ifdef
和 #endif
中。示例
/*jconfig.h for Microsoft Visual C++ on Windows 95 or NT. */
.....
#define BMP_SUPPORTED
#define GIF_SUPPORTED
.....
/* wrbmp.c */
....
#ifdef BMP_SUPPORTED
...
#endif /* BMP_SUPPORTED */
我建议立即选择要使用的内容并摆脱条件编译。例如,如果您决定 BMP 格式支持是必要的,则应从整个代码库中删除 #ifdef BMP_SUPPORTED
。
如果您必须保留创建多个程序版本的可能性,则应为每个版本创建测试。我建议保留最完整的版本并在此版本上工作。过渡完成后,您可以将条件编译指令重新添加回来。
但是我们还没有完成预处理器的工作。有必要找到模拟函数的预处理器命令,并将它们更改为真正的函数。
#define CACHE_STATE(tif, sp) do { \
BitAcc = sp->data; \
BitsAvail = sp->bit; \
EOLcnt = sp->EOLcnt; \
cp = (unsigned char*) tif->tif_rawcp; \
ep = cp + tif->tif_rawcc; \
} while (0)
为了正确签名一个函数,有必要找出所有参数的类型。请注意,BitAcc
、BitsAvail
、EOLcnt
、cp
和 ep
在预处理器命令中被赋值。这些变量将成为新函数的参数,并且应该通过引用传递。也就是说,您应该在函数签名中使用 uint32&
来表示 BitAcc
。
程序员有时会滥用预处理器。看看这种滥用的例子
#define HUFF_DECODE(result,state,htbl,failaction,slowlabel) \
{ register int nb, look; \
if (bits_left < HUFF_LOOKAHEAD) { \
if (! jpeg_fill_bit_buffer(&state,get_buffer,bits_left, 0)) {failaction;} \
get_buffer = state.get_buffer; bits_left = state.bits_left; \
if (bits_left < HUFF_LOOKAHEAD) { \
nb = 1; goto slowlabel; \
} \
} \
look = PEEK_BITS(HUFF_LOOKAHEAD); \
if ((nb = htbl->look_nbits[look]) != 0) { \
DROP_BITS(nb); \
result = htbl->look_sym[look]; \
} else { \
nb = HUFF_LOOKAHEAD+1; \
slowlabel: \
if ((result=jpeg_huff_decode(&state,get_buffer,bits_left,htbl,nb)) < 0) \
{ failaction; } \
get_buffer = state.get_buffer; bits_left = state.bits_left; \
} \
}
在上面的代码中,PEEK_BITS
和 DROP_BITS
也是“函数”,与 HUFF_DECODE
类似创建的。在这种情况下,最合理的方法可能是将 PEEK_BITS
和 DROP_BITS
“函数”的代码包含在 HUFF_DECODE
中,以简化迁移。
只有当剩下大多数无害(如下所示)的预处理器指令时,您才应该进入代码精炼的下一个阶段。
#define DATATYPE_VOID 0
2.3 switch 和 goto 语句
您可以通过引入布尔变量和/或更改函数代码来摆脱 goto
语句。例如,如果一个函数有一个使用 goto
跳出循环的循环,那么这样的结构可以更改为设置一个布尔变量、一个 break
子句,并在循环后检查该变量的值。
我的下一步是扫描代码,查找所有包含没有匹配 break
的 case
的 switch
语句。
switch ( test1(buf) )
{
case -1:
if ( line != buf + (bufsize - 1) )
continue;
/* falls through */
default:
fputs(buf, out);
break;
}
这在 C++ 中是允许的,但在 C# 中不允许。这种 switch
语句可以用 if
块替换,或者如果 fallthrough case
占用几行代码,您可以复制代码。
2.4 收集石头的时机
到目前为止我描述的都不应该花费太多时间 - 与接下来的事情相比。我们面临的第一个巨大任务是将数据和函数组合到类中。我们的目标是使每个函数都成为一个类的成员。 every function a method of a class.
如果代码最初是用 C++ 编写的,它可能包含很少的自由(非成员)函数。在这种情况下,应该找到现有类和自由函数之间的关系。通常,自由函数起着辅助类的作用。如果一个函数只在一个类中使用,它可以被移动到该类中作为 static
成员。如果一个函数在多个类中使用,那么可以创建一个新类,并将此函数作为其 static
成员。
如果代码是用 C 创建的,那么它将不包含任何类。它们将不得不从头开始创建,围绕它们操作的数据将函数分组。幸运的是,这种逻辑关系很容易弄清楚 - 尤其是如果 C 代码是使用一些 OOP 原则编写的。
让我们看下面的例子
struct tiff
{
char* tif_name;
int tif_fd;
int tif_mode;
uint32 tif_flags;
......
};
...
extern int TIFFDefaultDirectory(tiff*);
extern void _TIFFSetDefaultCompressionState(tiff*);
extern int TIFFSetCompressionScheme(tiff*, int);
...
很容易看出 tiff
struct
适合变成一个类,而下面声明的三个函数则成为这个类的 public
方法。因此,我们将 struct
改为 class
,并将三个函数改为类的 static
方法。
由于大多数函数将成为不同类的成员,因此更容易理解如何处理剩余的非成员函数。不要忘记,并非所有自由函数都会成为 public
方法。通常有一些辅助函数不打算从外部使用。这些函数将成为 private
方法。
在将自由函数更改为类的 static
方法之后,我建议开始用 new/delete
运算符替换对 malloc/free
函数的调用,并添加构造函数和析构函数。然后 static
方法可以逐渐转变为完整的类方法。随着越来越多的 static
方法转换为非 static
方法,将清楚至少有一个参数是多余的。这是指向已成为类的原始结构的指针。也可能发现 private
方法的某些参数可以成为成员变量。
2.5 预处理器再次出现和多重继承
现在类集合取代了函数和 struct
集合,是时候回到预处理器了。也就是说,回到像下面这样的定义(此时应该没有其他定义了)
#define STRIP_SIZE_DEFAULT 8192
这种定义应该转换为常量,您应该为它们找到或创建一个所有者类。与函数一样,新创建的常量可能需要为它们创建一个特殊的新类(可能称为 Constants
)。同样,常量也可以是 public
或 private
。
如果原始代码是用 C++ 编写的,它可能依赖于多重继承。这是在将代码转换为 C# 之前需要摆脱的另一件事。处理此问题的一种方法是更改类层次结构,以排除多重继承。另一种方法是确保使用多重继承的基类仅包含纯虚方法而不包含成员变量。例如
class A
{
public:
virtual bool DoSomething() = 0;
};
class B
{
public:
virtual bool DoAnother() = 0;
};
class C : public A, B
{ ... };
这种多重继承可以很容易地通过将 A 和 B 类声明为接口来转移到 C#。
2.6 typedef 语句
在进行下一个大规模任务(消除指针算术)之前,我们应该特别注意类型别名声明(typedef
语句)。有时它们被用作方便的类型简写。例如
typedef vector Commands;
我更喜欢内联这种声明 - 也就是说,在代码中找到 Commands
,将它们更改为 vector
,然后删除 typedef
。
typedef
使用的一个更有趣的例子是这个
typedef signed char int8;
typedef unsigned char uint8;
typedef short int16;
typedef unsigned short uint16;
typedef int int32;
typedef unsigned int uint32;
注意创建的类型的名称。很明显,typedef short int16
和 typedef int int32
在某种程度上是障碍,所以将代码中的 int16
改为 short
,将 int32
改为 int
是有意义的。另一方面,其他 typedefs
非常有用。然而,最好重命名它们,以便它们与 C# 中的类型名称匹配,例如
typedef signed char sbyte;
typedef unsigned char byte;
typedef unsigned short ushort
typedef unsigned int uint;
应特别注意以下声明:
typedef unsigned char JBLOCK[64]; /* one block of coefficients */
此声明将 JBLOCK
定义为类型为 unsigned char
的 64 个元素的数组。我更喜欢将此类声明转换为类。换句话说,创建一个 JBLOCK
类,它充当数组的包装器,并实现访问数组单个元素的方法。这有助于更好地理解 JBLOCK
数组(尤其是二维和三维数组)的创建、使用和销毁方式。
2.7. 指针算术
另一个大规模的任务是消除指针算术。许多 C/C++ 程序严重依赖于此语言特性。
例如:
void horAcc32(int stride, uint* wp, int wc)
{
if (wc > stride) {
wc -= stride;
do {
wp[stride] += wp[0];
wp++;
wc -= stride;
} while ((int)wc > 0);
}
}
这些函数需要重写,因为 C# 默认情况下无法使用指针算术。您可以在不安全代码中使用此类算术,但此类代码有其缺点。这就是为什么我更喜欢使用“索引算术”重写此类代码。它如下所示
void horAcc32(int stride, uint* wp, int wc)
{
int wpPos = 0;
if (wc > stride) {
wc -= stride;
do {
wp[wpPos + stride] += wp[wpPos];
wpPos++;
wc -= stride;
} while ((int)wc > 0);
}
}
生成的函数执行相同的工作,但它不使用指针算术,并且可以轻松移植到 C#。它也可能比原始版本稍慢,但同样,这现在不是我们的重点。
应特别注意那些修改作为参数传递的指针的函数。以下是一个此类函数的示例
void horAcc32(int stride, uint* & wp, int wc)
在这种情况下,更改函数 horAcc32
中的 wp
也会更改调用函数中的指针。尽管如此,引入索引仍是这里的一个合适的方法。您只需在调用函数中定义索引,并将其作为参数传递给 horAcc32
。
void horAcc32(int stride, uint* wp, int& wpPos, int wc)
将 int wpPos
转换为成员变量通常很方便。
2.8 函数指针
在指针算术完成之后,是时候处理函数指针了(如果代码中有的话)。函数指针可以有三种不同类型
- 函数指针在单个类/函数中创建和使用
- 函数指针由程序中的不同类创建和使用
- 函数指针由用户创建并传递给程序(此处程序是指动态或静态创建的库)
第一种类型的示例
typedef int (*func)(int x, int y);
class Calculator
{
Calculator();
int (*func)(int x, int y);
static int sum(int x, int y) { return x + y; }
static int mul(int x, int y) { return x * y; }
public:
static Calculator* CreateSummator()
{
Calculator* c = new Calculator();
c->func = sum;
return c;
}
static Calculator* CreateMultiplicator()
{
Calculator* c = new Calculator();
c->func = mul;
return c;
}
int Calc(int x, int y) { return (*func)(x,y); }
};
在这种情况下,Calc
方法的功能将取决于创建类实例时调用了 CreateSummator
还是 CreateMultiplicator
方法。我更喜欢在类中创建一个 private enum
,它描述了所有可能的功能选择以及存储 enum
值的字段。然后,而不是函数指针,我创建一个包含 switch
语句(或多个 if
)的方法。创建的方法根据字段值选择必要的功能。更改后的代码
class Calculator
{
enum FuncType
{ ftSum, ftMul };
FuncType type;
Calculator();
int func(int x, int y)
{
if (type == ftSum)
return sum(x,y);
return mul(x,y);
}
static int sum(int x, int y) { return x + y; }
static int mul(int x, int y) { return x * y; }
public:
static Calculator* createSummator()
{
Calculator* c = new Calculator();
c->type = ftSum;
return c;
}
static Calculator* createMultiplicator()
{
Calculator* c = new Calculator();
c->type = ftMul;
return c;
}
int Calc(int x, int y) { return func(x,y); }
};
您可以选择另一种方式:暂时不更改任何内容,在迁移到 C# 时使用委托。
第二种情况的示例(函数指针由程序中的不同类创建和使用)
typedef int (*TIFFVSetMethod)(TIFF*, ttag_t, va_list);
typedef int (*TIFFVGetMethod)(TIFF*, ttag_t, va_list);
typedef void (*TIFFPrintMethod)(TIFF*, FILE*, long);
class TIFFTagMethods
{
public:
TIFFVSetMethod vsetfield;
TIFFVGetMethod vgetfield;
TIFFPrintMethod printdir;
};
这种情况最好通过将 vsetfield/vgetfield/printdir
转换为虚方法来解决。使用 vsetfield/vgetfield/printdir
的代码将不得不创建一个派生自 TIFFTagMethods
的类,并实现必需的虚方法。
第三种情况的示例(函数指针由用户创建并传递给程序)
typedef int (*PROC)(int, int);
int DoUsingMyProc (int, int, PROC lpMyProc, ...);
委托最适合这种情况。也就是说,在这个阶段,在原始代码仍在进行完善时,不应该做其他事情。在后期,当项目迁移到 C# 时,应该用委托替换 PROC
,并将 DoUsingMyProc
函数更改为接受委托实例作为参数。
2.9 隔离“问题代码”
原始代码的最后一次更改是隔离任何可能对新编译器产生问题的代码。可能是积极使用标准 C/C++ 库(例如 fprintf
、gets
、atof
等函数)或 WinAPI 的代码。在 C# 中,这将不得不更改为使用 .NET Framework 方法,或者在需要时使用 p/invoke 技术。在后一种情况下,请查看 www.pinvoke.net 网站。
“问题代码”应尽可能本地化。为此,您可以为 C/C++ 标准库或 WinAPI 的函数创建一个包装器类。稍后只需要更改此包装器。
2.10 更换编译器
这是决定性的时刻 - 将更改后的代码引入使用 C# 编译器构建的新项目。这很简单,但工作量很大。需要创建一个新的空项目,然后将必要的类添加到该项目中,并将代码从相应的原始类复制到其中。
您将在此时删除冗余(例如各种 #includes
),并进行一些 косметические 修改。“标准”修改包括
- 合并来自
.h
和.cpp
文件的代码 - 将
obj->method()
替换为obj.method()
- 将
Class::StaticMethod
替换为Class.StaticMethod
- 在
func(A* anInstance)
中删除*
- 将
func(int& x)
替换为func(ref int x)
大多数修改并不特别复杂,但有些代码需要注释掉。主要是我在第 2.9 部分讨论的问题代码将被注释掉。这里的主要目标是获得可编译的 C# 代码。它很可能无法工作,但我们将在适当的时候进行处理。
2.11 让一切正常工作
在我们将转换后的代码编译成功后,我们需要调整代码,直到其功能与原始代码匹配。为此,我们需要创建第二个使用转换后代码的测试集。之前注释掉的方法需要仔细审查,并使用 .NET Framework 重写。我认为这部分不需要进一步解释。我只想扩展几点。
在创建从字节数组(反之亦然)到 string
的转换时,应仔细选择正确的编码。应避免使用 Encoding.ASCII
,因为它是 7 位编码。这意味着值大于 127 的字节将变成“?”而不是正确的字符。最好使用 Encoding.Default
或 Encoding.GetEncoding("Latin1")
。实际的编码选择取决于后续文本或字节的处理方式。如果文本要显示给用户,则 Encoding.Default
是更好的选择;如果文本要转换为字节并保存到二进制文件中,则 Encoding.GetEncoding("Latin1")
更合适。
格式化字符串的输出(C/C++ 中与 printf
函数族相关的代码)可能会带来一些问题。 .NET Framework 中的 String.Format
函数的功能都较弱且语法不同。这个问题可以通过两种方式解决
- 创建一个模仿
printf
函数功能的类 - 更改格式字符串,使
String.Format
显示相同的结果(并非总是可能)。
如果您选择第一种方法,请查看 “C# 中的 printf 实现”。
我更喜欢第二种方法。如果您也选择它,那么搜索 Google 上的“c# format specifiers”(不带引号)以及 “C# in a Nutshell”中的格式说明符附录可能会对您有所帮助。
当所有使用转换后代码的测试都成功通过后,我们可以确信转换已完成。现在我们可以回到代码尚未完全符合 C# 理念这一事实(例如,代码中充满了 get
/set
方法而不是属性),并处理转换后的代码的重构。您可以使用分析器来识别代码中的瓶颈并对其进行优化。但这又是另一回事了。
祝您移植顺利!