Именование переменных и констант в С++

Программирование Программирование на С++ Учебные материалы по С++ Именование переменных и констант в С++

Помечено: ,

В этой теме 0 ответов, 1 участник, последнее обновление  Васильев Владимир Сергеевич 1 год, 6 мес. назад.

Просмотр 1 сообщения - с 1 по 1 (всего 1)
  • Автор
    Сообщения
  • #3574

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

    В статье условно можно выделить три части:

    • Именование -> назначение -> использование. На ряде ярких примеров иллюстрируется цель и важность подбора правильных имен;
    • Некоторые проблемы. Неожиданно, но замена литералов типа 3.14 на именованные константы может приводить к ошибкам в программах.
    • Венгерская нотация — способ именования переменных, активно продвигаемый Microsoft (посмотрите на WinAPI) и его критика.

    Основная идея

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

    Именование

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

    По этому вместо того, что бы там и сям разбрасывать ничего не значащие числа по коду лучше использовать именованные константы. Кроме того, именованные константы удобнее и в случае если нужно изменить константное значение по всему коду. Представьте, что Вы будете исправлять константы по всему коду и пропустите пару мест, вероятно, что это приведет к трудно диагностируемым ошибкам.

    В Си есть несколько механизмов создания именованных символических констант: объекты отмеченные const, перечисления и объектно-подобные макросы. Каждый из этих механизмов имеет свои достоинства и недостатки.

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

    Предположим, пользователя интересует некоторое множество объектов. Например, он хочет последовательно проитерировать некоторый контейнер, выполнив какие-то действия над его содержимым, и вы предоставляете ему такую возможность. При этом совершенно не зачем информировать пользователя о том, что же это за контейнер, std::vector, std::list или boost::array. Эта информация является лишней. Она только собьет пользователя с мысли. Ему гораздо важнее что хранится в контейнере, а не то, как оно там хранится. Представьте себе, что вы пришли в ресторан, заказали блюдо из говядины, сидите, ждете заказ, и тут вас вдруг заставляют изучить весь сельскохозяйственный процесс по выращиванию и забою крупного рогатого скота вместо того, чтобы дать спокойно насладиться едой.

    Старайтесь брать в typedef все шаблонные типы с явно указанными шаблонными параметрами. Это даст возможность скрыть от пользователя детали реализации и слишком обобщенные имена, позволив вам присвоить сущности более верное с точки зрения семантики имя.

    Плохо:

    struct node
    {
        boost::shared_ptr<node> next;
        std::vector<boost::shared_ptr<node> > children;
    };
    node root;
    std::vector<boost::shared_ptr<node> >::iterator it = root.children.begin();

    Гораздо лучше:

    struct node;
    typedef boost::shared_ptr<node> node_ptr;
    struct node
    {
        typedef std::vector<node_ptr> container;
        node_ptr next;
        container children;
    };
    node root;
    node::container::iterator it = root.children.begin();

    Назначение

    Ваш инструмент готов. Так, по крайней мере, считаете вы. Однако, так ли это на самом деле? Действительно ли полученный инструмент имеет то же назначение, которое было запланировано? Проверить это очень просто — возьмите постороннего человека и задайте ему вопрос, как он считает, какого назначение изготовленного вами инструмента? Если человек ответит, что назначение инструмента очевидно и оно заключается в том же, что и было запланировано вами, то можете быть уверены — вам удалось сохранить смысл не привнеся никакой лишней информации. В противном случае вы либо завалили пользователя ненужной информацией, либо реализовать не ту функциональность, какая была запланирована. В любом случае это говорит о том, что вы ошиблись.

    Здесь мы хотели установить заголовок окна, но потеряли назначение по дороге:

    void on_init(window& win)
    {
        win.send_message(window::set_title, "My first application");
    }

    Гораздо лучше:

    void on_init(window& win)
    {
        win.set_title("My first application");
    }

    Использование

    Старайтесь реализовывать ваши инструменты таким образом, чтобы их использование для решения задач происходило по сценарию «Пришел, увидел, победил». Задача, которую можно объяснить тремя словами человеческого языка, должна решаться в одно действие с точки зрения кода. Ее решение не должно превращаться в детектив с запутанным сюжетом. Одна задача — одно действие.

    Запись в лог выглядит ужасно:

    std::stringstream stream;
    
    stream << get_current_date()
           << get_current_time()
           << get_current_thread()
           << "Application was successfuly initialized"
           << std::endl;
    
    log::instanse().write(log::info, stream.str());

    Уже лучше:

    // Сбор контекста спрятан внутри log::write
    log::instance().write(log::info, "Application was successfuly initialized");

    Прекрасно:

    // Сбор контекста и интерфейс синглтона спрятаны внутри log_message
    log_message(log::info, "Application was successfuly initialized");

    Подведем некоторые итоги

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

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

    Не создавайте сущностей, решающих сразу несколько задач. Каждая сущность должна решать одну конкретно поставленную задачу. Если на вопрос «Что делает ваша сущность?» вы не можете дать ясный ответ, состоящий их одного глагола и одного существительного, то, скорее всего, сущность нуждается в доработке.

    Имя сущности должно четко отражать ее назначение, из которого, в свою очередь, естественным образом должно вытекать ее использование. Иными словами, между понятиями «именование», «назначение» и «использование» должен стоять знак равенства. Соответствие имени сущности ее назначению и способу применения наполняет программный код однозначным смыслом.

    Помимо всего прочего, умение правильно именовать сущности вам особенно пригодится, если вам придется работать с языками, не имеющими статической типизации (например php, javascript, visualbasic и так далее).

    Хороший пример

    Не самая удачная функция из Win32 Platform SDK:

    BOOL AccessCheckByTypeResultListAndAuditAlarmByHandle
    (
        LPCTSTR SubsystemName,
        LPVOID HandleId,
        HANDLE ClientToken,
        LPCTSTR ObjectTypeName,
        LPCTSTR ObjectName,
        PSECURITY_DESCRIPTOR pSecurityDescriptor,
        PSID PrincipalSelfSid,
        DWORD DesiredAccess,
        AUDIT_EVENT_TYPE AuditType,
        DWORD Flags,
        POBJECT_TYPE_LIST ObjectTypeList,
        DWORD ObjectTypeListLength,
        PGENERIC_MAPPING GenericMapping,
        BOOL ObjectCreation,
        LPDWORD GrantedAccess,
        LPDWORD AccessStatusList,
        LPBOOL pfGenerateOnClose
    );

    Неожиданные проблемы

    Хорошие имена в программе — это прекрасно, однако в ряде случаев могут возникать неожиданные проблемы…

    Объекты отмеченные const

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

    Для константных объектов можно указывать точный тип, как ниже:

    const unsigned int buffer_size = 256;

    К сожалению, константные объекты не могут использоваться где уже во время компиляции требуются целочисленные константы, а конкретнее в следующих случаях:

    — размер битового поля в структуре
    — размер массива (кроме массивов переменной длинны)
    — значение константы перечисления
    — значение константы в case

    В любом из этих случаев в качестве rvalue значения должен использоваться литерал.

    Зато можно получить адрес константного объекта.

    const int max = 15;
    int a[max]; /* неверно вне функции! */
    const int *p;
     
    /* можно получить адрес константного объекта */
    p = &max;

    В большинстве случаев использование константных объектов ведет к большему использованию памяти и процессорного времени. Так, например, если константная переменная будет определена внутри функции она будет храниться в стеке и создаваться/уничтожаться каждый раз когда программа будет входить в/выходить из функции. Хотя, чаще всего эти затраты не существенны, о них нужно знать.
    Перечислимые константы (перечисления)

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

    enum  max = 15 ;
    int amax; /* Корректно вне функции */
    const int *p;
     
    p = &max; /* ошибка: нельзя получить адрес */

    Объекто-подобные макросы

    Программисты на Си часто определяют символические константы с помощью объектно-подобных макросов. Например:

    #define buffer_size 256

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

    Макросы не подчиняются правилам пространств имен и по тому могут подставляться в непредвиденных местах и с неизвестным заранее результатом.

    Объектные макросы не хранятся в памяти как переменные, следовательно на них нельзя сделать указателя. Так же они не позволяют производить контроль типов, так как у них просто нет типа.

    Обобщим

    В следующей таблице собраны выше обозначенные различия между разными видами констант.

    Макросы Перечисления const объекты
    Подставляется Препроцессором При компиляции В рантайме
    Потребляет память Нет Нет Да
    Видно в отладчиках Нет Да Да
    Проверка типа Нет Нет Да
    Вычисление во время компиляции Да Да Нет

    Смысл числа 18 не совсем ясен в данном примере:

    if age >= 18 
       /* Совершаем действия */
    
    else 
      /* Совершаем другое действие */


    Правильное решение

    Правильным решением будет замена целочисленного литерала на константу ADULT_AGE, которая сама говорит о своем назначении:

    enum  ADULT_AGE=18 ;
    /* ... */
    if (age >= ADULT_AGE )
       /* Совершаем действие */
    
    else 
      /* Совершаем другое действие */

    Пример с ошибкой №2

    Если вы надеетесь, что будете всегда помнить порядок следования параметров функций типа fgets, то можете писать и так:

    char buffer[256];
    /* ... */
    fgets(buffer, 256, stdin);

    Правильное решение (enum)

    Но гораздо понятнее будет с именованной константой:

    enum  BUFFER_SIZE=256 ;
     
    char buffer[BUFFER_SIZE];
    /* ... */
    fgets(buffer, BUFFER_SIZE, stdin);

    Правильное решение (sizeof)

    Справедливости ради нужно отметить, что читабельность кода можно повысить не только применением именованных констант, но и другими методами. Так выше приведенный пример можно переписать с использованием sizeof:

    char buffer[256];
    /* ... */
    fgets(buffer, sizeof(buffer), stdin);

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

    Пример с ошибкой №3

    В следующем примере значения localhost и 1234 вбиты прямо в логику программы, следовательно их можно изменить только в ручную корректируя код.

    LDAP *ld = ldap_init("localhost", 1234);
    if (ld == NULL) {
      perror("ldap_init");
      return 1;
    }

    Правильное решение

    Правильным решением будет определение макросов, которые можно передать с помощью опци -D командной строки компилятора.

     /* может быть передано через командную строку компилятора */
    #ifndef PORTNUMBER
    #  define PORTNUMBER 1234
    #endif
     
     /* может быть передано через командную строку компилятора */
    #ifndef HOSTNAME
    #  define HOSTNAME "localhost"
    #endif
     
    /* ... */
     
    LDAP *ld = ldap_init(HOSTNAME, PORTNUMBER);
    if (ld == NULL) {
      perror("ldap_init");
      return 1;
    }
    

    Исключение

    Исключение могут составлять выражения, в которых замена литералов на именованные константы не приводит к повышению читабельности кода, или упрощению его корректирования. Так выражение:

    x = -b + sqrt(b*b) - 4*a*c / 2*a;

    читается намного удобнее, чем

    enum  TWO = 2 ;     /* a scalar */
    enum  FOUR = 4 ;    /* a scalar */
    enum  SQUARE = 2 ;  /* an exponent */
    x = -b + sqrt(pow(b)), SQUARE - FOUR*a*c/ TWO * a;

    То есть в первую очередь нужно руководствоваться здравым смыслом.

    Венгерская нотация

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

    Лишняя информация

    Информация о точном типе переменной в ее имени — это лишняя информация. Любая лишняя информация сбивает пользователя с толку, и он начинает думать не о том, о чем должен. Конечно же, некоторая информация о типе пользователю все же может понадобиться, однако речь идет далеко не о том, является ли тип указателем на void, или же это строка в стиле Plain C.

    Во-первых, пользователю может понадобиться информация о размере переменной или объекта. С этой задачей прекрасно справится оператор sizeof.

    Во-вторых, пользователю нужно знать, является ли языковая сущность единичным объектом, или же это множество объектов, то есть контейнер. И не просто контейнер, а контейнер в человеческом понимании. То есть, std::string — единичный объект.

    Вся остальная информация является лишней. Если же для понимания программы пользователю все-таки требуется какая-то дополнительная информация, касающаяся типа той или иной сущности, то это просто означает, что система, с которой работает пользователь, плохо спроектирована, а предоставляемые сущности имеют плохие имена, не соответствующие формуле «Именование = назначение = использование».

    Замена типа

    Венгерская нотация создает массу проблем после любой замены типа. Любая замена типа в лучшем случае приведет к куче ошибок времени компиляции. В худшем — неочевидных и не всегда легко выявляемых ошибок времени выполнения.

    Во-первых, может просто возникнуть необходимость заменить один тип данных на другой. В этом случае придется ползать по всем исходникам и вносить исправления во все места, где успел «засветиться» объект или переменная.

    Во-вторых, если проект компилируется под несколько платформ, то тип данных, который на одной платформе является DWORD-ом, на другой платформе может оказаться уже QWORD-ом, а следовательно информация, содержащаяся в имени переменной уже не будет соответствовать действительности.

    Помимо всего прочего, попробуйте отвлечься от языка C++ и подумайте, каковы будут последствия замены типа в языках, не имеющих статической типизации.

    Отсутствие контроля

    Однако главный недостаток венгерской нотации заключается в том, что она является искусственным правилом, а не частью языковой грамматики. Например, никто не мешает взять переменную типа BYTE и назвать ее ulSize. Компилятору совершенно все равно — проверка этих соответствий не входит в круг его полномочий. В этом случае информация о типе будет являться не только лишней, но еще и не соответствующей действительности. По сути, разработчики, использующие венгерскую нотацию, переносят работу по контролю типов с плеч компилятора на свои собственные. Это один из ярких примеров того, как человек из-за сиюминутного удобства берется решать задачи, с которыми может прекрасно справиться машина. Использование всяческих префиксов и суффиксов по сути не дает никаких гарантий, при этом притупляя бдительность пользователя и давая ему еще один шанс допустить ошибку.

    Мотивация

    В реальной жизни все же существуют задачи, когда пользователю действительно нужна полная информация о типе. Например, передача данных на внешнее устройство — мы хотим точно быть уверены в том, что данные передаются именно по байтам. Или же — вычисление контрольной суммы — нам нужна четкая гарантия того, что работа с числами идет по правилам беззнаковой арифметики. В таких случаях нужно создать всяческие защитные механизмы, как уровня компиляции, так и уровня выполнения, которые позволят дать гарантию того, что работа с объектами и переменными идет согласно с предъявляемыми требованиями. Как вы понимаете, простое добавление префиксов в имена не дает абсолютно никаких гарантий. Вопрос производительности защитных механизмов решается элементарно. Для механизмов уровня компиляции, очевидно, этот вопрос не стоит вообще; механизмы же уровня выполнения реализуются, как вариант, следующим образом:

    class safe_byte
    {
        // Реализация (в том числе всей арифметики)
        // со всевозможными проверками
    };
    
    #if defined (debug)
    typedef safe_byte byte;
    #else
    typedef unsigned char byte;
    #endif // debug

    Завязка на размер

    Существуют ситуации, когда нужны четкие гарантии относительно размера типа. Чаще всего такие ситуации возникают, когда возникает необходимость обмениваться данными внутри системы, в которой каждый участник общения может быть скомпилирован под разные платформы. Например — сетевой маршрутизатор, общающийся по своему внутреннему протоколу с себе подобными, скомпилированный как под 32, так и под 64 бита.

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

    namespace network
    {
    
    namespace detail
    {
    
    #if defined (platform_16)
    typedef unsigned char      byte;
    typedef unsigned int       word;
    typedef unsigned long int  dword;
    #elif defined (platform_32)
    typedef unsigned char      byte;
    typedef unsigned short int word;
    typedef unsigned int       dword;
    #elif defined (platform_64)
    typedef unsigned char      byte;
    typedef unsigned short int word;
    typedef unsigned int       dword;
    #else
    #error Unknown platform
    #endif
    
    } // namespace detail
    
    } // namespace network

    Заключение

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

Просмотр 1 сообщения - с 1 по 1 (всего 1)

Для ответа в этой теме необходимо авторизоваться.