9 сентября 2012 г.

Константы без макросов в C++

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

Использовался компилятор GCC 4.7.0 (из MinGW).


Пример

Для примера представим следующую задачу - есть код который опрашивает состояние двух кнопок, и мигает двумя светодиодами, используя некоторый алгоритм зависящий от частоты CPU. Есть несколько, скажем два, устройства на которых должен работать данный код. Собственно, устройства отличаются между собой номерами пинов, к которым подключены кнопки и светодиоды, и частотой CPU.

Решение на С

В языке C такая задача традиционно решается с помощью макросов, например так:
#if DEVICE == 1
    #define BUTTON1_PIN 1
    #define BUTTON2_PIN 2
    #define LED1_PIN    10
    #define LED2_PIN    11
    #define CPU_FREQ    10000000
#elif DEVICE == 2
    #define BUTTON1_PIN 5
    #define BUTTON2_PIN 6
    #define LED1_PIN    20
    #define LED2_PIN    21
    #define CPU_FREQ    20000000
#else
    #error Unsupported device
#endif
Для выбора платформы необходимо определить константу DEVICE равную 1 или 2, и перекомпилировать код.

Минусы 

У этого подходя есть несколько минусов. Во первых, он не является безопасным с точки зрения типов. Например, предположим что у нас есть функция light(int led_pin), которая зажигает светодиод идентифицируемый по номеру пина. К сожалению, если ошибочно передать пин идентифицирующий кнопку, код будет компилироваться без ошибок: light(BUTTON1_PIN). Все потому, что макросы не являются языковым средством - по сути, это просто умная автозамена.

Вторая проблема заключается в неудобстве поддержки данного подхода. Например, что бы добавить поддержку для новой кнопки нужно отыскать определения констант для всех поддерживаемых платформ, и для каждой платформы добавить определение номера пина новой кнопки (например BUTTON3_PIN). Разумеется, если для какой-то из платформ забыть это сделать, но начать использовать в коде константу BUTTON3_PIN то код под эту платформу просто перестанет компилироваться. Неудобно также и добавлять поддержку новой платформы, т.к. что бы быть уверенным что все необходимые константы определены, нужно внимательно посмотреть на код для других платформ (и надеяться что там определены все константы).

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

Решение на С++

Самый очевидный вариант это, конечно, просто использовать глобальные константные переменные, например так:
typedef unsigned char LedPin;
const LedPin LED1_PIN = 1;
Но, во первых - этот подход может негативно сказаться на производительности и размере бинарного файла. Ведь вы можете захотеть получить адрес такой переменной, поэтому компилятор не может просто выкинуть её и использовать везде константу. Конечно, если адрес переменной нигде не используется, компилятор мог бы выполнить такую оптимизацию, но полагаться на это не стоит.

Во вторых - этот подход не решает большинства проблем. Возьмем хотя бы проблему с типами:
typedef unsigned char LedPin;
typedef unsigned char ButtonPin;

bool getState(LedPin p)
{
    // ... проверить горит ли светодиод и вернуть результат ...
}

bool getState(ButtonPin p)
{
    // ... проверить нажата ли кнопка и вернуть результат ...
}
Этот код не скомпилируется, т.к. компилятор не сможет разрешить перегрузку функции getState,  потому что LedPin и ButtonPin это всего лишь синонимы для типа unsigned char, а не новый тип. Мы могли бы воспользоваться чем-то вроде strong typedefs, но едва ли это улучшит ситуацию. 

Более подходящий вариант это использование перечислений (конечно, перечисления есть и в C, но там имя перечисления не является именем типа, и это, вместе со слабой системой контроля типов, делает перечисления неподходящим инструментом для решения нашей задачи):
enum LedPin
{
    LED1_PIN = 10,
    LED2_PIN = 11
};

enum ButtonPin
{
    BUTTON1_PIN = 1,
    BUTTON2_PIN = 2
};

bool getState(LedPin p)
{
    // ... проверить горит ли светодиод и вернуть результат ...
}

bool getState(ButtonPin p)
{
    // ... проверить нажата ли кнопка и вернуть результат ...
}
Это уже намного лучше. На самом деле, решены почти все проблемы кроме проблемы поддержки - разделять определения пинов для разных платформ все равно придется с помощью директив условной компиляции, и мы не сможем легко убедиться что определения для всех поддерживаемых платформ написаны правильно.

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

Второй класс, собственно спецификация платформы, которая содержит конкретные определения (номер пина кнопки 1 равен 1, номер пина кнопки 2 равен 2, частота равна 10000000 и т.д.) соответствующие описанию спецификации платформы. Хотелось бы, что бы компилятор мог сам проверять что спецификации всех поддерживаемых платформ соответствуют описанию, и в случае если нет - выдавать понятное сообщение об ошибке.

Сделать это совсем не сложно. Начнем со спецификации платформы - каждая спецификация для конкретной платформы представляет собой структуру, содержащую необходимые определения:
struct Device1
{
    enum ButtonPin
    {
        BUTTON1 = 1,
        BUTTON2 = 2
    };
 
    enum LedPin
    {
        LED1 = 10,
        LED2 = 11
    };

    enum SystemInfo
    {
        CPU_FREQ = 10000000
    };
};
(аналогичным образом определим и Device2).

Теперь описание спецификации платформы. Эта сущность, по сути, должна обобщать понятие платформы (в нашем случае - структуры с определениями) и в этом нам помогут шаблоны:
template <class ConcreteDevice>
struct Device
{
    enum ButtonPin
    {
        BUTTON1 = ConcreteDevice::BUTTON1,
        BUTTON2 = ConcreteDevice::BUTTON2
    };

    enum LedPin
    {
        LED1 = ConcreteDevice::LED1,
        LED2 = ConcreteDevice::LED2
    };

    enum SystemInfo
    {
        CPU_FREQ = ConcreteDevice::CPU_FREQ
    };
};
Фактически, это просто шаблон структуры который извлекает список необходимых ему определений из шаблонного параметра.

Но это еще не все - нужно заставить компилятор проверить, что все спецификации устройств соответствуют описанию. Это легко сделать с помощью явной конкретизации шаблона (explicit template instantiation):
template class Device<Device1>;
template class Device<Device2>;
Это заставит компилятор сгенерировать экземпляры шаблона Device конкретизированные классами Device1 и Device2, и, тем самым, проверить на соответствие спецификации. Что будет если в Device2 забыли поместить определение LED2?
  In instantiation of 'class Device<Device2>': 
  required from here 
  error: 'LED2' is not a member of 'Device2' 
По моему, очень наглядно.

Ну и выбрать нужную платформу на этапе компиляции совсем просто:
#if DEVICE == 1
    typedef Device<Device1> CurrentDevice;
#elif DEVICE == 2
    typedef Device<Device2> CurrentDevice;
#else
    #error Unsupported device
#endif
Нужные константы будут доступны через класс CurrentDevice: CurrentDevice::LED1, CurrentDevice::CPU_FREQ.

Преимущества

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

Недостатки

Основной недостаток заключается в том, что при использовании перечислений нам доступны лишь целочисленные константы. Но, на самом деле, добавить поддержку дробных чисел и строковых констант не так сложно, хотя код будет менее элегантным.
Самый подходящий, на мой взгляд, способ заключается в том, что все не целочисленные константы выносятся в отдельные однострочные статические методы наших структур-спецификаций:
static float FLOAT_VAL() { return 4.2f; }
static const char* STR_VAL() { return "This is device 1"; }
С большой вероятностью их вызовы будут встраиваемыми (inline), а значит с точки зрения производительности эти методы будут эквивалентны константам-макросам.

Что бы мы могли вызывать эти методы как статические методы CurrentDevice, шаблонную структуру Device нужно унаследовать от её же шаблонного параметра ConcreteDevice:
template <class ConcreteDevice>
struct Device: public ConcreteDevice
Что бы заставить компилятор выполнить нужную нам проверку на наличие определенных методов-констант в спецификациях платформ, достаточно в шаблон описания спецификаций добавить метод проверяющий их наличие:
static void check()
{
    (void)ConcreteDevice::FLOAT_VAL();
    (void)ConcreteDevice::STR_VAL();
}
Повторюсь, решение не слишком элегантное (хотя и полностью рабочее). Но, думается, использование дробных и строковых констант в такой ситуации - дело весьма экзотическое.

Выводы

И под конец хотелось бы сказать что несмотря на то что шаблоны - достаточно удобное средство для выполнения различных проверок на этапе компиляции, иногда более удачные результаты все же дают всякие ухищрения с макросами. Вот, например, самая удачная, на мой взгляд, реализация статической проверки static assert:
#define CONCATENATE(x, y) CONCATENATE_ (x, y)
#define CONCATENATE_(x, y) x##y
#define STATIC_ASSERT(expression) typedef char CONCATENATE(assertion_failed_at_line_, __LINE__) [(expression) ? 1 : -1]
Удачность её заключается в достаточно выразительном описании места ошибки, например, для кода:
STATIC_ASSERT(sizeof(int) == 1);
описание ошибки будет выглядеть так:
  error: size of array 'assertion_failed_at_line_10' is negative
что весьма неплохо (но напомню, что начиная с С++11 в языке C++ появилась языковая поддержка static_assert).

Файлы

Здесь можно скачать файлы с кодом иллюстрирующим описанный подход: скачать.

Комментариев нет:

Отправить комментарий