Сборник занимательных задач по языку программирования C
Часть I
Запутанные задачи по C и C++.
Приоритеты операторов
Задача
Многие программисты используют свой компьютер для очень странных вещей: чтобы принять решение, они пишут программу, которая случайным образом печатает «орел» и «решка» — вместо того, чтобы бросить монетку. Если вы долго писали на C, а теперь решили изучить C++, вас наверняка волнует, стоит ли это делать? Правда, что C++ лучше, чем C? Мы не станем отдавать решение этого вопроса слепому случаю, а применим свой опыт программиста.
Мы напишем такую программу:
#include <stdio.h>
void main()
{
int C = 0;
puts("Учить ли Си++?");
if (C > C++)
puts("Не учить.");
else if (C == C++)
puts("Не знаю.");
else
puts("Учить");
}
Теперь, не запуская код, скажите, что напечатает программа.
Обсуждение
Посмотрев в таблицу приоритетов и порядка разбора операторов, узнаём, что операторы <
, ==
и >
, разбираются слева направо. Это должно приводить к результату «Не знаю».
А теперь запустите программу и проверьте.
Если вы работаете на PC и используете один из общеизвестных компиляторов, то, вероятно, увидите ответ «Не учить». Почему?
Потому что в этой программе значение имеет не то, в каком порядке выполняются операторы, а то, в каком порядке вычисляются их операнды. Рассмотрим выражение
a * b + c * d + e * f
. Порядок разбора (ассоциативность) оператора +
, согласно таблице гарантирует разбор слева направо, то есть так: (a * b + c * d) + e * f
. Но ассоциативность
не влияет на то, в каком порядке будут вычислены значения выражений a * b
, c * d
и e * f
. Если e * f
вычисляется строкой выше, то компилятор хранит
результат произведения в регистре, поэтому использует его. a * b
и c * d
могут быть вычислены после e * f
:
int t3 = e * f;
int t1 = a * b;
int t2 = c * d;
int r = (t1 + t2) + t3;
Стандарт C утверждает, что порядок вычисления операндов не регламентирован, и разработчики компилятора вольны выбирать его по своему усмотрению. В нашей задаче C++ может быть вычислено раньше C, а может быть и позже. Даже один и тот же компилятор может выдавать разные результаты при включенной и отключенной оптимизации.
Порядок вычисления операндов неважен, пока мы используем операторы без побочных эффектов, но такие операторы, как ++
и -=
не только возвращают значение переменной, но и изменяют её. Если
переменная встречается в выражении несколько раз, невозможно предсказать, в каком месте возникнет её новое значение. Именно поэтому в C и C++ надо учитывать возможные побочные эффекты:
/* так писать можно */
int i = 0;
while (i < n)
a[i++] = value;
/* так писать нельзя */
int i = 0;
while (i < n - 1)
a[i] = a[i++];
/* надо переписать так */
int i = 0;
while (i < n - 1) {
a[i] = a[i + 1];
i++;
}
В Java и C# операнды вычисляются слева направо, когда это возможно. В выражении a(b(), c())
сначала будут вычислены методы b
и c
, а затем a
, потому что ему требуются
значения b
и c
. Но даже в этих языках код надо писать так, чтобы он не вызывал вопросов у читателя, пришедшего из C и C++.
Есть только три оператора, для которых порядок вычисления операндов закреплён стандартом: &&
, ||
и «запятая»:
bool contains(const char* array, size_t length, char value)
{
size_t i = 0;
while (i < length && array[i++] != value)
;
return i < length;
}
В этой функции i
и array[i++]
можно использовать в одном выражении, поскольку правый операнд оператора &&
будет вычислена только в том случае, если левый операнд истинен.
Символьные константы
Задача
Что будет напечатано в результате выполнения этого кода?
printf("Буква \0x41\n");
В ASCII символ с кодом 0x41
— это буква ‘A’ английского алфавита. Из этого и исходите.
Обсуждение
Префикс 0x
означает, что целочисленная константа записана в шестнадцатиричной системе счисления. Но если мы хотим указать шестнадцатиричный код символа внутри строки,
нужен префикс \x
. Запись "Буква \0x41\n"
означает для компилятора {'Б', 'у', 'к', 'в', 'а', ' ', '\0', 'x', '4', '1', '\n'}
. В статическую память программы будут помещены все 11
символов, но строковые функции стандартной библиотеки C интепретируют '\0'
как конец строки, поэтому printf
вывыдет только символы перед '\0'
, то есть строку "Буква "
с пробелом
на конце.
Эту ошибку мы искали вдвоём в течение полутора часов — при взгляде мельком запись выглядит вполне естественно.
Метки
Задача
Функция detect012
вызвана с параметром 3. Что будет напечатано на экране?
void detect012(int i)
{
switch (i)
{
case 0:
puts("Ноль");
break;
case 1:
puts("Один");
break;
case 2:
puts("Два");
break;
defualt:
puts("Неизвестно");
break;
}
}
Обсуждение
В K&R читаем:
Если выяснилось, что ни одна из констант не подходит, то выполняется ветвь, помеченная словом
default
, если таковая имеется, в противном случае ничего не делается. (стр.63)
В том, что ветви default
в приводимом примере нет, можно убедиться, внимательно перечитав листинг. В коде встречается метка defualt
, в которой переставлены буквы ‘u’ и ‘a’.
Это обычная опечатка, которую трудно увидеть замыленным глазом. В 1989 Microsoft C Compiler версии 5.10 не выдавал на этот код никаких предупреждений, и я потратил на поиск
ошибки несколько часов. Сейчас компиляторы стали дружелюбнее, и подсказывают, что на метку defualt
нет ни одного перехода.
Комментарии
Задача
Перед вами пример программы, которая должна вычислить значение π5. Она действительно это делает?
#include <stdio.h>
#define PI 3.1415926535897932385
void main(void)
{
int i;
double d = 1.0;
/*************************
for (i = 0; i < 5; i++) ** Здесь вычисляется Пи **
d *= PI; ** в пятой степени **
*************************/
printf("Пи в степени %d равно %lf\n", i, d);
}
Обсуждение
В C комментарии ограничиваются символами /*
и */
, поэтому всё, что окажется между ними, компилятор проигнорирует, в том числе и эти две строки:
for (i = 0; i < 5; i++)
d *= PI;
Программа напечатает число 1
, которое конечно не равно π5, то есть 306,018392
.
Код кажется неправдоподобным, но я встречал подобную конструкцию в реальной программе. Найдя причину ошибки, я для себя сделал вывод, что рамочки это конечно красиво, но простота оформления кода гораздо важнее.
Приоритет унарных операторов
Задача
Как разбирается инструкция ++i++
:
- так:
++(i++)
- или так:
(++i)++
?
Если i == 0
, чему будет равно значение выражения ++i++
?
Обсуждение
Операторы ++
и --
применяются только к Л-значениям, поскольку они присвают значение. Л-значениями называются такие выражения, которые могут
стоять в левой части оператора присваивания. Они могут быть сложными как a[foo(3 * i +12)]
, и в то же время некоторые простые конструкции,
наподобие i + 1
, не могут быть Л-значениями.
/* несуразность */
i + 1 = 10;
Вернёмся к задаче. Запись ++i++
так же бессмысленна, как и i + 1 = i + 1
. Эта инструкция вообще не будет
компилироваться, и вы получите ошибку «требуется Л-значение» (L-value required).
Унарный минус
Задача
Будет ли компилироваться инструкция -i++
?
Если i == 0
, чему будет равно значение выражения -i++
?
Обсуждение
Эта инструкция имеет одно важное отличие от предыдущей: операнд унарного минуса не обязан быть Л-значением, поскольку
выражение -(i + 1)
вполне корректно. Весь вопрос сводится к тому, как компилятор представляет эту инструкцию: как (-i)++
или как -(i++)
?
В таблице приоритетов и порядка разбора операторов, видим, что унарный минус и инкремент находятся во второй
строчке, там же указано, что они разбираются справа налево. Следовательно сначала будет выполняться оператор ++
, а затем —
унарный минус.
Ответ: это выражение будет принято компилятором. Значение этого выражения равно -(i + 1)
и при i == 0
будет равно -1
.
Аргументы командной строки
Задача
Функция main
описана так:
int main(int foo, char **bar)
Можно ли писать так, или мы обязаны использовать в качестве параметров main
переменные argc
и argv
?
Обсуждение
Оказывается, не обязаны. Имена параметров функции main
в стандарте не оговорены, важен только их тип, поэтому такая
запись вполне корректна.
О способах сжатия программ на C
Задача
Однажды мы с Булатом Зиганшиным обсуждали процесс архивации. Я предположил, что если архиватор знает, что файл — это текст программы на языке Си, он может сжимать его сильнее. Например,
инструкция while
всегда записывается так:
while (condition)
operator
Впрочем, если вспомнить стандарт, то окажется, что в любом месте, где можно написать пробел, можно написать и комментарий:
while /* комментарий */ (condition)
operator
И даже так:
while
// комментарий
(condition)
operator
В любом случае, сразу после инструкции while
следует ноль или больше пробельных литер или комментариев, а затем условное выражение в скобках.
Владея этой информацией, архиватор мог бы хранить код оператора while
и не хранить скобки.
Эти рассуждения правдоподобны.
Можно ли написать такой архиватор? Есть ли другие способы записать инструкцию while
?
Обсуждение
Препроцессор компилятора позволяет писать код, который не выглядит как программа на C, но, тем не менее, ей является.
Пример, который привёл Булат:
while
#include "foo.h"
operator
Файл foo.h
будет выглядеть так:
(condition)
Дима Борток предложил другое решение:
#define condition (условное выражение)
while condition
оператор
Директива include
Задача
Параметр директвы #include
должен быть заключён в кавычки или угловые скобки. Могут ли встречаться другие символы?
#include "foo.h"
#include <bar>
Обсуждение
#ifdef WIN
#define IO "winio.h"
#else
#define IO <stdio.h>
#endif
#include IO
Этот пример не описан в K&R, но он присутствует в «Рабочем проекте» комитета X3J11. Параметризация включаемых файлов может быть полезна, а такой метод не вносит дополнительной путаницы и разрешен многими существующими компиляторами. Он включён в стандарт ANSI.
for
Задача
Можно ли ускорить выполнение этого кода?
int i;
for (i = 0; i < 10; i++);
Обсуждение
int i = 10;
Макроопределения и шаблоны
Задача
Рассмотрим пример совместного использования макроопределения и шаблона. Всё ли хорошо с этим кодом?
#define isPositive(value1, value2) (value1 > 0)?(value2):(0)
template<int N, int M> int sum() { return N + M; }
std::cout << isPositive(sum<2, 3>(), 10);
Обсуждение
Такая конструкция не будет даже компилироваться: компилятор выдаст ошибку о неверном числе параметров макроопределения. Препроцессор не понимает, что sum<2, 3>()
является неделимой
синтаксической конструкцией, и интерпретирует запятую, как разделитель параметров макроопределения. Так происходит потому, что угловые скобки в C++ имеют двойной смысл,
и для того, чтобы правильно разобрать выражение foo<const1, const2>(var1)
, необходимо знать, что foo
является шаблоном. Этой информацией владеет компилятор, но у препроцессора её нет.
Обратите внимание, что foo<const1, const2>(var1)
является синтаксически корректной конструкцией в C и C++, поскольку мы имеем выражения foo<const1
и const2>(var1)
, разделённые оператором «запятая».
Круглые скобки воспринимаются препроцессором правильно. Оон не может судить о типе своих операндов, но в состоянии определять парность скобок. Поэтому решить проблему можно, расставив скобки:
std::cout << isPositive((sum<2, 3>()), 10);
Массивы
Задача
Предположим, вы переходите на C с Pascal и вместо непривычной инструкции a[i][j]
пишите привычную a[i, j]
в таком вызове:
printf("next = %d\n", a[i, j]);
Как на это прореагирует компилятор?
Обсуждение
В C есть редко используемый оператор запятая «,», который позволяет вычислить значения нескольких выражений и возвращает значение последнего из них.
В коде a[i, j]
выражение i
будет вычислено и отброшено, а выражение j
будет вычислено и оставлено, в результате у нас получится a[j]
.
printf("next = %d\n", a[j]);
Компилятор мог бы обнаружить ошибку, если бы использование a[j]
вместо a[i][j]
приводило к несоответствию типов, но при вызове printf
этого не происходит. Дело в том, что
printf
работает с произвольным числом параметров. Эта возможность реализутся в C без дополнительных накладных расходов, можно сказать, при помощи трюка.
В действительности вызываемая функция не знает количество и типы переданных ей параметров и контроль за типами становится ответственностью программиста, а не компилятора.
Опираясь на строку %d
функция printf
будет интерпретировать содержимое участка памяти, как целое число, в то время как там хранится адрес массива целых чисел, j
-ый в списке.
Код будет скомпилирован, запустится, и даже, возможно, будет работать, но работать неправильно.
Современные компиляторы C могут выдать предупреждение, так как они умеют разбирать и проверять строки форматирования.
Условный оператор
Задача
Посмотрите на фрагмент функции. Сможет ли она обнаружить ошибку если файл не найден?
if (NULL == (in = fopen(fname, "r")));
{
perror(fname);
return -1;
}
Обсуждение
Код из реальной программы, в котором замыленный глаз не видит очевидную опечатку: точку запятой после оператора if
. В C есть понятие «пустого оператора» поэтому такой код
вполне корректен.
if (condition)
;
else
;
Из-за пустого оператора после if
функция будет обнаруживать ошибку даже если файл найден и успешно октрыт. Если корректно переформатировать код, он примет такой вид:
if (NULL == (in = fopen(fname, "r")))
;
perror(fname);
return -1;
Теперь ошибка сразу бросается в глаза.
- Назад
- Содержание
- Вперёд