Затачиваем старый код под новые реалии

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

Для чего это делать? К примеру, я таким образом портировал известную графическую библиотеку LibTiff (и LibJpeg заодно) на C#. Это позволило использовать наработки многих людей, создававших LibTiff, в моей программе вместе с библиотекой классов .NET Framework. Примеры кода в статье будут в основном из LibTiff и LibJpeg.

1. Инфраструктура


Что потребуется:

  • Оригинальный код, который вы можете собрать «за один клик».
  • Набор тестов, который также можно выполнить «за один клик».
  • Система контроля версий.
  • Базовые понятия о рефакторинге.


Требования собирать за «один клик» и выполнять «за один клик» нужны для того, чтобы максимально ускорить цикл «изменили-скомпилировали-запустили тесты». Чем больше усилий требуется для выполнения одного такого цикла, тем реже он будет выполняться. Это может привести к сложным и масштабным откатам неудачных изменений.

Система контроля версий подойдет любая. Я в работе использую Subversion – вы, в свою очередь, можете использовать то, что вам удобно. Главное использовать хоть что-то кроме папок на диске.

Тесты потребуются для того, чтобы в любой момент времени убедиться, что код все еще делает то, что должен, и так же, как раньше. Уверенность в том, что код функционально не меняется, – главное отличие описываемого метода от метода «напишем все с нуля на новом языке». От тестов не требуется покрывать 100% кода, но для всего основного функционала желательно тесты иметь. Желательно, чтобы тесты не имели доступ к внутреннему устройству программы, это позволит избежать постоянного переписывания тестов.

Например, для портирования LibTiff я использовал:

  • набор изображений в разных вариантах формата TIFF.
  • консольную программу tiffcp, которая конвертирует изображения из одного варианта TIFF в другой вариант.
  • набор скриптов (bat-файлы), которые вызывают tiffcp для конвертирования.
  • набор ожидаемых выходных изображений.
  • программу для бинарного сравнения изображений, полученных после конвертирования, с ожидаемыми изображениями.



О рефакторинге достаточно прочитать всего одну книгу. Это книга Мартина Фаулера «Рефакторинг. Улучшение существующего кода». Если вы ее не читали, то прочтите обязательно – для любого программиста знание принципов рефакторинга только полезно. Всю книгу читать не обязательно. Достаточно прочитать около 130 страниц от начала. Это первые пять частей и начало шестой части, до раздела «Встраивание метода».

Разумеется, чем лучше вы знаете языки, между которыми будет трансформироваться код, тем легче вам будет. Заметьте, что знание устройства оригинального кода не требуется. Для начала достаточно знать что делает исходный код. Как он это делает, станет ясно в процессе трансформации.

2. Процесс переноса


Суть метода состоит в том, что оригинальный код большим количеством простых и мелких изменений приводится к упрощенному виду, сохраняя при этом свои возможности. Не нужно пытаться сразу изменить большой кусок кода и еще оптимизировать его впридачу. Продвигаться нужно как можно более мелкими шажками и после каждого шага прогонять тесты и фиксировать удачные изменения. То есть изменили немного — проверили. Если все в порядке – залили изменения в хранилище системы версий.

Процесс переноса можно разделить на три больших этапа:

  1. В оригинальном коде постепенно заменяется все, что использует специфические возможности исходного языка, на более простое, но эквивалентное по функционалу. Это часто ведет к тому, что код начинает медленнее работать и не столь красиво выглядит. Не стоит об этом беспокоиться на данном этапе.
  2. Измененный код приводится к виду, который сможет собрать новый компилятор.
  3. Переносятся тесты, и код на новом языке доводится до совпадения по функционалу с оригинальным кодом.


Лишь после выполнения всех этих пунктов стоит вспомнить о скорости и красоте кода.

Основную сложность представляет первый этап. На данном этапе нужно преобразовать код на C/C++ в код на «чистом C++», максимально близкий по синтаксису к C#. На этом этапе нужно избавиться от:

  • директив препроцессора
  • операторов goto
  • операторов typedef
  • арифметики указателей
  • указателей на функции
  • множественного наследования
  • “свободных” функций


Перейдем к рассмотрению конкретных шагов.

2.1 Удаляем неиспользуемый код


Первым делом стоит удалить неиспользуемые части кода. Например, из LibTiff я сначала удалил все файлы, которые не относились к сборке Windows-версии. Потом в оставшихся файлах нашел операторы условной компиляции, в которые был заключен код, игнорируемый компилятором Visual Studio, и тоже их удалил. Примеры такого кода:

#if defined(__BORLANDC__) || defined(__MINGW32__)
# define XMD_H 1
#endif</code>
 
<code>#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 присваиваются значения. Эти переменные станут параметрами новой функции, и передавать их нужно по ссылке. То есть, например, для 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 = 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 и проверку значения переменной после цикла.

Следующим этапом я проверяю все конструкции switch на предмет наличия case-ов c отсутствующими break.

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



Это позволяется в C/C++, но запрещено в C#. Такие операторы switch можно или заменять на несколько блоков if, или, если case с fallthrough состоит из пары строк, дублировать общий код.

2.4 Собираем камни


Все описанное ранее требует достаточно малого количества времени по сравнению дальнейшими задачами. Первой такой масштабной задачей является сбор данных и функций в классы. Целью является ситуации, когда каждая функция является методом какого-либо класса.

Если код был изначально написан на 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 и сделать функции статическими методами класса.

По мере того, как большинство функций будет становиться методами классов, станет легче понять, что делать с оставшимися «свободными» функциями. Не нужно забывать о том, что не все функции станут публичными методами. Обычно есть некоторое количество вспомогательных функций, не предназначенных для использования извне. Такие вспомогательные функции станут частными (private) методами.

После того, как функции стали статическими методами классов, я советую заняться заменой malloc/free на new/delete и добавлением конструкторов с деструкторами. Затем статические методы начинаем превращать в полноценные методы класса. По мере того, как методы будут переставать быть статическими, станет видно, что как минимум один параметр у них лишний. Это указатель на первоначальную структуру, которая стала классом. Разумеется, от такой избыточности нужно избавиться. Также может оказаться, что часть параметров у приватных функций можно сделать переменными членами класса.

2.5 Снова препроцессор и множественное наследование


После того, как из набора функций и структур получился набор классов, пора вернуться к препроцессору. А точнее, к define-ам вроде приведенного ниже (других у вас к этому моменту быть уже не должно):

#define STRIP_SIZE_DEFAULT 8192

Подобные дефайны нужно превратить в константы и подобрать им класс, который станет их владельцем. Так же как и в случае с функциями, для вновь созданных констант, возможно, потребуется создать класс (например, Constants). Также как и функции, константы могут стать публичными или частными.

Если оригинальный код был написан на C++, то в нем может использоваться множественное наследование. Это еще одна вещь, от которой нужно избавиться для переноса на C#. Один из способов: изменить иерархию классов так, чтобы множественное наследование было исключено. Другой способ состоит в том, чтобы все классы, которые используются для множественного наследования, содержали только чисто виртуальные (pure virtual) методы и не содержали переменных. Пример:

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<Command*> Commands;

Такие конструкции я предпочитаю встраивать, то есть менять в коде Commands на vector<Command*>, и удалять.

Более интересны следующие применения 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]; /* one block of coefficients */

Данная конструкция вводит название JBLOCK для массива из 64 элементов типа unsigned char. Такие конструкции я предпочитаю превращать в классы. То есть делать JBLOCK классом, который содержит внутри себя массив, и предоставляет методы для доступа к элементам массива. Такой подход значительно упрощает понимание того, как создаются и удаляются массивы JBLOCK-ов (особенно 2-х и 3-мерные), а также как они изменяются в процессе работы программы.

2.7 Арифметика указателей


Очередная масштабная задача — избавление от арифметики указателей (pointer-arithmetic). Многие программы на 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 полем класса (member variable).

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); }
};




В данном случае от того, какой из методов CreateSummator или CreateMultiplicator будет вызван, зависит функционал метода Calc в созданном классе. Я предпочитаю создать в классе внутренний enum, в котором описаны все возможные варианты для func, и поле, которое хранит значение из 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. Советую в таком случае заглянуть на сайт http://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 Framework. Думаю, этот этап можно особо не пояснять. Хочу лишь обратить внимание на пару моментов.

При создание строк из массива байт (и наоборот) нужно внимательно выбирать используемую кодировку. Не стоит использовать Encoding.ASCII, так как она 7-битная и для байт больше 127 получится ‘?’ вместо символов. Лучше использовать текущую кодировку Encoding.Default или Encoding.GetEncoding(«Latin1»). Выбор кодировки зависит от того, что дальше будет с текстом или байтами. Если текст нужно будет показать пользователю – то лучше использовать Encoding.Default, а если из текста делаются байты для записи в бинарный файл – то лучше использовать Encoding.GetEncoding(«Latin1»).

Определенные проблемы может составить вывод форматированных строк (семейство функций printf в C/C++). Функционал String.Format в .NET отличается как по возможностям (он беднее), так и синтаксисом строки форматирования. Эту проблему можно решить двумя способами:

  • создать класс, который будет выполнять то же самое, что функция printf
  • изменить строки форматирования так, чтобы String.Format давал аналогичные результаты (не всегда возможно)


Если идти по первому пути, то стоит обратить внимание на уже существующую реализацию «A printf implementation in C#».

Я предпочитаю второй путь. Если идти по нему, то поможет гугление по «c# format specifiers» (без кавычек) и «Format Specifiers Appendix from C# in a Nutshell».

После того, как все тесты, использующие перенесенный код, станут успешно выполняться, можно будет с уверенностью сказать, что перенос завершен. Вот теперь можно вспомнить о том, что код пока не вполне «в духе C#» (например, вместо свойств используются get-/set- методы) и заняться рефакторингом перенесенного кода. Можно при помощи профайлера поискать «узкие места» и заняться оптимизацией. Но это уже совсем другая история.

Удачного вам портирования!

Автор материала: Sergius Bobrovsky



Опубликовал admin
29 Сен, Среда 2010г.



Программирование для чайников.