Меня зовут Андрей Карпов. Я занимаюсь созданием инструментов для разработчиков и люблю писать статьи, посвященные качеству кода. В связи с этим, я познакомился с замечательным человеком Уолтером Брайтом, который является создателем языка D. В формате интервью я попробую узнать у него, как язык D помогает избавиться от ошибок, которые мы допускаем при программировании.
Уолтер Брайт (англ. Walter Bright) — программист, известный как главный разработчик первого "родного" компилятора C++ Zortech C++ (позже ставшего Symantec C++, а затем Digital Mars C++) и создатель языка D.
Что представляет из себя язык D?
D это объектно-ориентированный язык, задуманный как усовершенствованный вариант языка Си++. Однако, не смотря на схожесть, D не является разновидностью языка Си++. В нём были заново реализованы некоторые возможности и сделан уклон в сторону безопасности. Основным источником информации о языке D является сайт dlang.org. Также можно посетить страницу Wikipedia "D (язык программирования)" и сайт компании Digital Mars.
Лозунги "надо писать программы без ошибок и тогда не будут нужны вспомогательные инструменты" не имеют смысла. Мы все делали, делаем и будем делать ошибки при программировании. Конечно, нужно улучшать стиль кодирования, делать обзоры кода, использовать различные методологии, которые снизят количество ошибок. Однако ошибки всё равно будут. И если компилятор поможет выявить хотя бы часть из них, это замечательно. Именно поэтому я считаю отличной идей, вводить специальные механизмы в язык программирования, которые помогут избежать ошибок. В языке D этому уделяется существенное внимание и это замечательно.
Надо понимать, что дизайн языка и компилятор способны защитить только от части ошибок. Если программист написал алгоритм, который считает по неправильной формуле, ему ничем не помочь. Чтобы искать такие ошибки, нужен искусственный интеллект. Однако многие типовые ошибки достаточно просты и связаны с человеческой невнимательностью, усталостью, опечатками. Вот здесь синтаксис языка программирования и предупреждения компилятор могут существенно помочь в выявлении дефектов.
Каковы ошибки, возникающие из-за невнимательности? Сложный вопрос. Однако у меня есть кое-что, что позволит правдоподобно ответить на него. Испытывая анализатор PVS-Studio, мы анализируем различные open-source проекты. Найденные ошибки мы помещаем в базу данных. И чем больше ошибок определенного типа мы находим, тем больше примеров появляется у соответствующей диагностики. Конечно, анализатор ищет не все возможные типы ошибок. Однако диагностик уже много, чтобы выявлять закономерности.
Просто так нельзя взять и посмотреть, какие диагностики обнаружили максимальное количество ошибок. Новые диагностические правила появляются постепенно. Поэтому, чем раньше была реализована диагностика, тем большее проектов было проверено с её помощью. Я объединил схожие диагностики, ввёл поправочный конфидент и произвёл некоторые расчёты. Не буду утруждать читателя подробностями. Просто приведу список из 10 диагностик, которые выявляют максимальное количество ошибок: V547+V560, V595, V501, V512+V579, V557, V567+V610, V597, V519, V576, V530. По приведённым ссылкам можно увидеть примеры обнаруженных ошибок. Можно сказать, что это "top 10" типовых ошибок, которые делают программисты на языке Си/Си++.
Прошу прощения, что отвлекся от основной темы. Я хотел показать, что обсуждаемые типовые ошибки не выдуманы мной, а являются реальной бедой при разработке программ. Я рассмотрю эти разновидности ошибок и хочу узнать ответы у Уолтера на следующие вопросы:
Причин когда возникает бессмысленное условие множество. Это может быть опечатка, неаккуратный рефакторинг, неправильный тип. Компилятор Си/Си++ лояльно относится к условиям, которые всегда истинны или ложны. Иногда такие выражения полезны. Например, можно встретить подобный код:
#define DEBUG_ON 0
if (DEBUG_ON && Foo()) Dump(X);
Однако, от этой возможности очень много вреда (примеры, примеры) . Вот один из типовых примеров:
std::string::size_type pos = dtaFileName.rfind("/");
if (pos < 0) {
pos = dtaFileName.rfind("\\");
}
Переменная 'pos' имеет без знаковый тип. Поэтому условие (pos < 0) всегда ложно.
Комментарий Уолтера:
Многие из моих комментариев обоснованы с той точки зрения, что обсуждаемые проблемы следует решать средствами самого языка. При этом приходится либо вычищать 100% ложных срабатываний, либо придумывать простую заплатку, которая не выглядела бы криво и всегда работала. Какой-нибудь вспомогательный инструмент для проверки вполне может давать несколько ложных срабатываний время от времени.
Конструкция unsigned<0 обычно является ошибкой в том случае, если она существует в коде высшего уровня. Однако она вполне допустима в обобщённом коде в качестве граничного случая. Она также может использоваться в коде для проверки на тип unsigned, как в случае с in -(T)1<0. Так что я бы не стал однозначно называть такой код всегда ошибочным.
Зачем проверяют указатель на равенство нулю, объяснять не надо. Но не многие догадываются, как хрупок этот код. Очень часто в процессе неаккуратного рефакторинга, указатель начинают использовать до проверки (примеры). Например, подобная ошибка может выглядеть так:
buf = buf->next;
pos = buf->pos;
if(!buf) return -1;
Подобный код, может долгое время работать, до того момента, как возникнет нестандартная ситуация и указатель окажется равен нулю.
Комментарий Уолтера:
Мне не приходит в голову ни одного случая, когда допустимо такое действие. Но тут действительно нужен качественный анализ потока данных, чтобы удостовериться, что buf не подвергается никаким промежуточным модификациям.
Примеры ошибок, найденные с помощью диагностики V501 являются хорошей демонстрацией, почему вредно использовать Copy-Paste. Впрочем, совсем без Copy-Paste программировать тоже утомительно. Так что эта разновидность ошибок очень живучая. Рассмотрим пример:
if( m_GamePad[iUserIndex].wButtons ||
m_GamePad[iUserIndex].sThumbLX ||
m_GamePad[iUserIndex].sThumbLX ||
m_GamePad[iUserIndex].sThumbRX ||
m_GamePad[iUserIndex].sThumbRY ||
m_GamePad[iUserIndex].bLeftTrigger ||
m_GamePad[iUserIndex].bRightTrigger )
В подобном коде так соблазнительно скопировать строчку и немного поправить её. Результатом такого желания, будет странное поведение программы при специфическом стечении обстоятельств. Если читатель не заметил ошибку, то подскажу. Два раза проверяется член класса 'sThumbLX', но нет проверки для 'sThumbLY'
Комментарий Уолтера:
Я изучал этот вопрос для реализации в D. Но проблема в том, чтобы проверить, не имеет ли дублированное условие побочных эффектов и не имеют ли какие-нибудь условия между копиями побочных эффектов, которые могут повлиять на результат дублированного условия. Чтобы проверка работала надёжно и не давала ложных срабатываний, требуется качественный анализ потока данных.
Существует также проблема обобщённого кода и встраивания функций, что может привести к появлению копий, которые при этом не будут ошибками. Поэтому проверка дублированных конструкций должна проводится до расширения обобщённого кода и встраивания функций, но расширение обобщённого кода и встраивание функций должны в свою очередь осуществляться до того, как будет произведен корректный анализ потока данных. Так что поиск правильного решения данного вопроса представляет собой специфичную проблему сродни определению причины и следствия в известном вопросе про курицу и яйцо.
Типовой ошибкой при использовании таких функций как memset, memcpy, strncmp, является обработка только части буфера. Ошибка возникает, когда вместо размера буфера вычисляется размер указателя. Кажется, такая ошибка должна сразу быть выявлена. Однако такие ошибки живут в программах многие годы (примеры, примеры). Например, приведённый ниже код для проверки целостности таблицы почти работает.
const char * keyword;
....
if (strncmp(piece, keyword, sizeof(keyword)) != 0) {
HUNSPELL_WARNING(stderr,
"error: line %d: table is corrupt\n", af->getlinenum());
Сравнивается только часть ключевого слова. А именно 4 или 8 байт, в зависимости от размера указателя на данной платформе.
Комментарий Уолтера:
Эти несчастные ошибки очень хорошо изгоняются из кода, если придерживаться синтаксиса языка D в массивах:
if (piece.startsWith(keyword)) ...
Вряд ли вы увидите функции memset, strncmp и т.д. в D коде. Массивы в D знают свои размеры, поэтому ошибки с неправильной длиной массива, характерные для C, остались в прошлом. По моему не очень скромному мнению, самая большая ошибка C заключалась в отделении длины от массивов при их передаче в функции, а вторая самая большая ошибка - использование нуль-терминальных строк. В D исправлены оба этих недочёта.
Классика программистского жанра. Способов совершить такие ошибки огромное множество:
Если посмотреть здесь, то можно подобрать пример к каждому из перечисленных пунктов. Приведу только один самый простой пример:
#define FINDBUFFLEN 64 // Max buffer find/replace size
static char findWhat[FINDBUFFLEN] = {'\0'};
....
findWhat[FINDBUFFLEN] = '\0';
Комментарий Уолтера:
Данная проблема во многом похожа на пункт (4). Массивы в D реальны и знают собственную длину. Проверка границ массива производится по умолчанию (но её по желанию можно отключить с помощью ключа командной строки). Проблема выхода за границы массива теперь покоится на свалке истории. Конечно в D всё так же можно использовать сырые указатели и производить над ними арифметические действия, но использоваться подобным образом они должны лишь изредка и не являться нормой, а опыт показывает, что сырые указатели следует преобразовывать в массивы как можно скорее.
Этот тип ошибки является наиболее дискуссионным. Хотя стандарт четко говорит, что какая-то конструкция приведёт к неопределённому поведению, разработчики часто с этим не соглашаются. Их аргументы:
Я не хочу вновь возвращаться к обсуждению и спорить, можно так писать или нет. Каждый программист сам должен сделать для себя выводы. Лично я считаю это ничем неоправданным риском, который через некоторое время может неожиданно вылиться во многие часы отладки.
Разнообразные примеры можно посмотреть здесь и здесь. Приведу пару опасных конструкций:
m_Quant.IQuant = m_Quant.IQuant--;
intptr_t Val = -1;
Val <<= PointerLikeTypeTraits<PointerTy>::NumLowBitsAvailable;
В первом случае переменная изменяется дважды в одной точке следования. Во втором происходит сдвиг отрицательного значения.
Комментарий Уолтера:
Решение данной проблемы в языке D состоит в стремлении убрать максимальное количество случаев неопределённого поведения. Например, порядок вычисления выражений строго определён (слева направо).
Часто программисты не знают, что компилятор предпринимает весьма специфичные оптимизации. Например, он может удалить вызов функции memset(), которая заполняет буфер, если потом этот буфер больше нигде не используется. В результате, если в буфере хранился пароль или что-то ещё важное, эти данные останутся в памяти. Это является потенциальной уязвимостью. Подробнее про это я писал в статье "Безопасность, безопасность! А вы её тестируете?". Соответствующие примеры.
Пример:
void CSHA1::Final()
{
UINT_8 finalcount[8];
...
memset(finalcount, 0, 8);
Transform(m_state, m_buffer);
}
Буфер "finalcount" после вызова функции memset() более не используется. Значит, вызов этой функции может быть удалён.
Комментарий Уолтера:
Право на удаление вырожденных присваиваний - важная оптимизация компилятора. Такие присваивания появляются при реализации обобщённого кода и встраивании функций, а также как следствие других оптимизаций. Чтобы принудительно выполнить вырожденное присваивание в C, цель должна быть объявлена как переменная. В D же единственный способ сделать это - написать код на встроенном ассемблере. Встроенный ассемблер - это абсолютный способ "получить то, что написал".
Два раза записать значение в одну и ту же переменную, это конечно не ошибка. Это бывает полезно. Например, когда результат работы функции не нужен, но хочется его знать в процессе отладки. Тогда результат помещается во временную переменную. Код может выглядеть так:
status = Foo(x);
status = Foo(y);
status = Foo(z);
Однако я считаю, что компилятору стоит предупреждать о таком коде. Достаточно часто, можно встретить подобные ошибки:
t=x1; x1=x2; x2=t;
t=y1; x1=y2; y2=t;
Обмен значений в переменных написан неправильно. Здесь значения два раза подряд присваиваются переменной x1. Вторая строка должна была быть: "t=y1; y1=y2; y2=t;". Вот только не надо говорить, что это студенческий код и что вы такие ошибки не делаете. Этот код, между прочим, взят из библиотеки QT. А вот другие примеры подобных ошибок в серьезных программах.
Комментарий Уолтера:
Двойное присваивание - это разновидность вырожденных присваиваний, которые я прокомментировал в пункте (7).
Тема, что функции printf, scanf и т.д. опасны, настолько стара и избита, что не буду останавливаться на ней. Я говорю о ней только потому, что таких ошибок очень много в программах на Си/Си++ (примеры). И интересно узнать, как обстоит с этим дело в языке D.
Комментарий Уолтера:
В D вы можете вызвать функцию языка C printf и использовать её, в том числе неправильно, точно так же, как и в C. Однако при вызове функций языка D он имеет возможность запрашивать типы аргументов непосредственно во время выполнения программы. Поэтому std.stdio.writefln() безопасна по типам.
В D существует еще один особенность: в шаблонах тоже могут использоваться списки аргументов с переменным количеством аргументов. Их использование точно так же безопасно по типам и проверяется во время компиляции.
Часто результат работы функции проверять не надо. Однако есть функции, которые подразумевают, что их результат обязательно используют. Например, это функция fopen(). К сожалению, имена некоторых функций оказались в языке Си++ не очень удачными. И это провоцирует ошибки, на подобии этой:
void VariantValue::Clear()
{
m_vtype = VT_NULL;
m_bvalue = false;
m_ivalue = 0;
m_fvalue = 0;
m_svalue.empty();
m_tvalue = 0;
}
Вместо функции clear() вызывается функция empty(). Это весьма распространенная ошибка (примеры).
Вся беда в том, что в языке Си/Си++ нет понятия, что результат функции должен обязательно использоваться. Конечно, есть различные расширения языка на эту тему. Но это не совсем то. Кто-то ими пользуется?
Комментарий Уолтера:
В D нет какого-то специального правила, гласящего, что возвращаемое функцией значение должно быть использовано. По моему опыту, возвращаемые значения, которые игнорируются, зачастую являются кодами ошибок, и вместо них язык D поощряет использование исключений для указания ошибок.
Есть и другие распространенные паттерны типовых ошибок. Например, ошибки, обнаруженные с использованием диагностики V517. Но, к сожалению, где-то надо остановиться.
Итог обзора приведённых паттернов ошибок достаточно ожидаем. На язык и компилятор можно взвалить поиск далеко не всех разновидностей ошибок. То, как работает код часто не так очевидно и разобраться в нём пока может только человек. Однако, видна огромная работа, проделанная в сторону безопасного программирования. И хорошим тому примером является язык D. В статье показано, как язык D, будучи схожим с языком Си/Си++, позволяет избежать множества его проблем. Это замечательно. Желаю успехов этому языку и предлагаю разработчиком взглянуть на него внимательнее.
0