Адаптація старого коду до нових реалій

Ця стаття описує корисні техніки перетворення старого коду C/C++ на повністю керований код C#. Ми використали ці методи для портування класичних бібліотек libjpeg і libtiff на .NET.

pic

Вступ

У цій статті я описую один метод, який можна використовувати для перетворення коду C/C++ на код C# з мінімальними зусиллями. Принципи, викладені в цій статті, також підходять для інших пар мов програмування. Хочу одразу попередити, що цей метод не підходить для портування коду, що стосується графічного інтерфейсу користувача (GUI).

Для чого це корисно? Наприклад, я використав цей метод для портування libtiff, відомої бібліотеки TIFF, на C# (а також для libjpeg). Це дозволило мені використовувати роботу багатьох людей, які долучилися до libtiff, у моїй програмі для .NET. Приклади коду в статті здебільшого з бібліотек libtiff і libjpeg.

1. Передумови

Що вам знадобиться:

  • Оригінальний код, який можна зібрати “за один клік”
  • Набір тестів, що також виконуються “за один клік”
  • Система контролю версій
  • Базове розуміння принципів рефакторингу

Вимога “однокрокової” збірки та запуску тестів потрібна для максимально швидкого циклу “зміна — компіляція — запуск тестів”. Чим більше часу та зусиль витрачається на кожен такий цикл, тим менше разів він буде виконуватись. Це може призвести до масових і складних скасувань помилкових змін.

Ви можете використовувати будь-яку систему контролю версій. Я використовую Git — ви можете вибрати будь-яку, з якою вам зручно працювати. Будь-що, крім набору папок на жорсткому диску, підійде.

Тести необхідні для того, щоб бути впевненим, що код зберігає всі свої функціональні можливості на будь-якому етапі. Бути впевненим, що в коді не з’явилися функціональні зміни — це те, що відрізняє мій метод від підходу “давайте перепишемо все з нуля на новій мові”. Тести не повинні покривати 100% коду, але бажано, щоб тести були для всіх ключових функцій коду. Тести не повинні звертатися до внутрішньої реалізації коду, щоб уникнути постійного переписування їх.

Ось що я використовував для портування LibTiff:

  • Набір зображень у форматі TIFF
  • tiffcp, утиліта командного рядка для конвертації TIFF- зображень між різними схемами стиснення
  • Набір пакетних скриптів, що використовують tiffcp для завдань конвертації
  • Набір вихідних зображень для перевірки
  • Програма, яка виконує бінарну порівняльну перевірку вихідних зображень з набором еталонних зображень

Щоб зрозуміти концепції рефакторингу, вам достатньо прочитати одну книгу. Martin Fowler, Refactoring: Improving the Design of Existing Code. Обов'язково прочитайте її, якщо ви ще цього не зробили. Кожен розробник повинен знати принципи рефакторингу. Не обов'язково читати всю книгу. Перші 130 сторінок з початку буде достатньо. Це перші п’ять глав і початок шостої, до “Inline Method”.

Чим краще ви знаєте мови, що використовуються в вашому вихідному та цільовому коді, тим легше буде перетворення. Зверніть увагу, що глибоке знання внутрішньої реалізації оригінального коду не потрібне на початку. Досить зрозуміти, що робить оригінальний код. Глибше розуміння того, як він це робить, прийде в процесі.

2. Процес трансформації

Суть методу полягає в тому, що оригінальний код спрощується через серію простих і малих рефакторингів. Не намагайтеся змінити великий блок коду і спробувати оптимізувати його одразу. Потрібно рухатися маленькими кроками, запускати тести після кожного циклу змін і зберігати кожну успішну модифікацію. Зробили маленьку зміну — протестували. Якщо все добре, збережіть зміну в репозиторії.

У процесі трансформації є 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 для Microsoft Visual C++ на Windows 95 або 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 призначаються в межах команди препроцесора. Ці змінні стануть аргументами нових функцій і повинні передаватися за посиланням. Тому для BitAcc в підписі функції потрібно використовувати uint32&.

Іноді програмісти зловживають препроцесором. Ось приклад такого зловживання:

#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  
 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 та перевірку значення змінної після циклу.

Наступний крок — перевірити код на наявність усіх операторів switch, що містять case, без відповідного break.

switch ( test1(buf) )  
{  
 case -1:  
 if ( line != buf + (bufsize - 1) )  
 continue;  
 /* falls through */  
 default:  
 fputs(buf, out);  
 break;  
}

C++ дозволяє таке, але C# — ні. Замість таких операторів switch використовуйте блоки if, або можна дублювати код, якщо перехід між case займає лише кілька рядків.

2.4 Час збирати каміння

Все, що я описав до цього, не повинно забирати багато часу — не порівняно з тим, що чекає попереду. Перше велике завдання, яке перед нами стоїть, — це об’єднання даних і функцій у класи. Метою є перетворення кожної функції на метод класу.

Якщо код був написаний спочатку на C++, ймовірно, він містить кілька вільних (не членських) функцій. Знайдіть зв'язок між існуючими класами та вільними функціями. Зазвичай виявляється, що вільні функції виконують допоміжну роль для класів. Якщо лише один клас використовує функцію, перемістіть її в цей клас як static метод. Якщо кілька класів використовують функцію, створіть новий клас, а цю функцію зробіть його static членом.

Якщо код був написаний на C, в ньому не буде класів. Їх потрібно буде створити, групуючи функції навколо даних, з якими вони працюють. На щастя, це логічне співвідношення досить легко виявити — особливо якщо код на C використовує деякі принципи ООП.

Розглянемо приклад:

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 на class, а три функції — на static методи класу.

Оскільки більшість функцій перетворюються на методи різних класів, буде легше зрозуміти, що робити з іншими вільними функціями. Не забувайте, що не всі вільні функції стануть публічними методами. Зазвичай є кілька допоміжних функцій, не призначених для зовнішнього використання. Ці функції стануть приватними методами.

Після того як ви перетворите вільні функції на static методи класів, я пропоную перейти до заміни викликів функцій malloc/free на оператори new/delete і додавання конструкторів та деструкторів. Потім можна поступово перетворювати static методи на повноцінні методи класів. Оскільки ви будете перетворювати все більше і більше статичних методів на нестатичні, стане зрозуміло, що хоча б один з їхніх аргументів є зайвим. Це вказівник на оригінальну struct, яка стала класом. Можливо, також з’ясується, що деякі аргументи private методів можна перетворити на члени класу.

2.5 Знову препроцесор та множинне наслідування

Тепер, коли набір класів замінив набір функцій і структур, час повернутися до препроцесора. Для таких визначень, як наведену нижче (більше не повинно залишитися інших):

#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  
{ ... };

Таке множинне наслідування можна легко перенести на C#, оголосивши класи A та B як інтерфейси.

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 у коді. Інші typedef є досить корисними. Однак, доцільно перейменувати їх, щоб вони відповідали іменам типів у C#, ось так:

typedef signed char sbyte;  
typedef unsigned char byte;  
typedef unsigned short ushort  
typedef unsigned int uint;

Особливу увагу слід звернути на декларації, подібні до такої:

typedef unsigned char JBLOCK[64]; /* один блок коефіцієнтів */

Ця декларація визначає JBLOCK як масив з 64 елементів типу unsigned char. Я надаю перевагу перетворенню таких декларацій у класи. Іншими словами, створіть клас 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#. Ви можете використовувати таку арифметику в небезпечному коді (unsafe code), але такий код має свої недоліки. Тому я надаю перевагу переписуванню такого коду, використовуючи "індексну арифметику". Це виглядає так:

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)

Тут зміна wp у функції horAcc32 також змінює вказівник у викликаючій функції.
Однак введення індексу буде підходящим варіантом тут. Просто потрібно визначити індекс у викликаючій функції та передати його в horAcc32 як аргумент.

void horAcc32(int stride, uint* wp, int& wpPos, int wc)

Зазвичай зручно перетворити int wpPos на змінну-член класу.

2.8 Вказівники на функції

Після того, як арифметика вказівників буде вирішена, на черзі обробка вказівників на функції (якщо такі є в коді). Вказівники на функції можуть бути трьох типів:

  1. Вказівники на функції створюються та використовуються всередині одного класу / функції
  2. Вказівники на функції створюються та використовуються різними класами в програмі
  3. Вказівники на функції створюються користувачами та передаються в програму. Тут програма є динамічною або статичною бібліотекою.

Приклад першого типу:

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 або, у разі потреби, техніку p/invoke. Для цього можна ознайомитися з сайтом www.pinvoke.net.

Ізолюйте "проблемний код" наскільки це можливо. Наприклад, можна створити клас-обгортку для функцій зі стандартної бібліотеки C/C++ або WinAPI. Тільки цей обгортковий клас потрібно буде змінювати пізніше.

2.10 Зміна компілятора

Це момент істини — час перенести змінений код в новий проєкт, який використовує C# компілятор. Це досить тривіально, але трудомістко. Потрібно створити новий порожній проєкт, потім додати до нього порожні класи. Після цього скопіювати код з відповідних оригінальних класів.

На цьому етапі вам доведеться позбутися зайвого (наприклад, різних #include) і зробити деякі косметичні зміни. "Стандартні" зміни включають:
* об’єднання коду з файлів .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. Я думаю, що ця частина не потребує додаткових пояснень. Однак я хотів би звернути увагу на кілька важливих моментів.

При створенні рядків з масивів байтів (і навпаки), обережно обирайте правильну кодування. Уникайте використання Encoding.ASCII через її 7-бітову природу. Це означає, що байти зі значеннями, більшими за 127, будуть замінюватися на ?, а не на правильні символи. Найкраще використовувати Encoding.Default або Encoding.GetEncoding("Latin1"). Конкретний вибір кодування залежить від того, що буде робитися з текстом або байтами після цього. Якщо текст буде відображатися користувачеві, то Encoding.Default є кращим вибором, а якщо текст буде конвертований у байти та збережений у бінарний файл, то краще використовувати Encoding.GetEncoding("Latin1").

Виведення відформатованих рядків (код, що стосується родини функцій printf у C/C++) може викликати певні проблеми. Функціональність String.Format в .NET є як біднішою, так і має інший синтаксис. Це можна вирішити двома способами:

  • Створити клас, який імітує функціональність функцій printf
  • Змінити форматні рядки так, щоб String.Format виводив той самий результат (не завжди можливо).

Я надаю перевагу другому варіанту. Якщо ви виберете цей шлях, то пошук по запиту c# format specifiers в Google та Додаток B. Форматні Специфікатори з книги C# in a Nutshell може бути корисним.

Конвертація вважається завершеною, коли всі тести, що використовують перетворений код, успішно проходять. Тепер можна повернутися до того факту, що код не зовсім відповідає ідеології C# (наприклад, код, повний методів get/set, замість властивостей) та відрефакторити перетворений код. Ви можете використовувати профайлер для виявлення вузьких місць у коді та його оптимізації. Але це вже зовсім інша історія.

Удачі в портуванні!

Перекладено з: Adapting old code to new realities

Leave a Reply

Your email address will not be published. Required fields are marked *