На главную

Сборник занимательных задач по языку программирования 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 == 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;

Теперь ошибка сразу бросается в глаза.