![]() |
||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Основы языка Си: структура Си-программы, базовые типы и конструирование новых типов, операции и выражения Основы языка Си
Имена заголовочных файлов имеют расширение ".h". Имена файлов реализации имеют расширения ".c" для языка Си и ".cpp", ".cxx" или ".cc" для языка C++.
|
||||||||||||||||||||||||||||||
размер переменной |
размер типа |
значение |
sizeof(i) |
sizeof(int) |
4 |
sizeof(c) |
sizeof(char) |
1 |
sizeof(s) |
sizeof(short) |
2 |
sizeof(l) |
sizeof(long) |
4 |
sizeof(d) |
sizeof(double) |
8 |
sizeof(f) |
sizeof(float) |
4 |
sizeof(b) |
sizeof(bool) |
1 |
Слово void означает "пустота". Тип void в Си обозначает отсутствие чего-либо там, где обычно предполагается описание типа. Например, функция, не возвращающая никакого значения, в Си описывается как возвращающая значение типа void:
void f(int x);
Другое применение ключевого слова void состоит в описании указателя общего типа, когда заранее не известен тип объекта, на который он будет ссылаться.
Для создания новых типов в Си можно использовать конструкции массива, указателя и структуры.
Описание массива в Си состоит из имени базового типа, названия массива и его размера, который указывается в квадратных скобках. Размер массива обязательно должен быть целочисленной константой или константным выражением. Примеры:
int a[10];
char c[256];
double d[1000];
В первой строке описан массив целых чисел из 10 элементов. Подчеркнем, что нумерация в Си всегда начинается с нуля, так что индексы элементов массива изменяются в пределах от 0 до 9. Во второй строке описан массив символов из 256 элементов (индексы в пределах 0...255), в третьей - массив вещественных чисел из 1000 элементов (индексы в пределах 0...999). Для доступа к элементу массива указывается имя массива и индекс элемента в квадратных скобках, например,
a[0], c[255], d[123].
Оператор sizeof возвращает размер всего массива в байтах, а не в элементах массива. В данном примере
sizeof(a) = 10*sizeof(int) = 40,
sizeof(c) = 256*sizeof(char) = 256,
sizeof(d) = 1000*sizeof(double) = 8000.
Указатели - это переменные, которые хранят адреса объектов. Указатели - фамильная принадлежность языка Си. В неявном виде указатели присутствовали и в других языках программирования, но в Си они используются гораздо чаще, а работа с указателями огранизована максимально просто.
При описании указателя надо задать тип объектов, адреса которых будут содержаться в нем. Перед именем указателя при описании ставится звездочка, чтобы отличить его от обычной переменной. Примеры описаний указателей:
int *a, *b, c, d;
char *e;
void *f;
В первой строке описаны указатели a и b на тип int и простые переменныe c и d типа int (c и d - не указатели!).
С указателями возможны следующие два действия:
a = &c;
указателю a присваивает значение адреса переменной c;
d = *a;
присваивает переменной d значение целочисленной переменной, адрес которой содержится в a. Так как ранее указателю a был присвоен адрес переменной c, то в результате переменной d присваивается значение c, т.е. данная строка эквивалентна следующей:
d = c;
Ниже будут рассмотрены также арифметические операции с указателями, которые в языке Си чрезвычайно важны.
Конструкции массива и указателя при описании типа можно применять многократно в произвольном порядке. Кроме того, можно описывать прототип функции. Таким образом можно строить сложные описания вроде "массив указателей", "указатель на указатель", "указатель на массив", "функция, возвращающая значение типа указатель", "указатель на функцию" и т.д. Правила здесь таковы:
int *(x[10]);
означает "массив из 10 элементов типа указатель на int";
int *x[10];
означает "массив из 10 элементов типа указатель на int". Здесь к имени переменной x сначала применяется операция определения массива [] (квадратные скобки), поскольку она имеет более высокий приоритет, чем звездочка. Затем к полученному массиву применяется операция определения указателя. В результате получается "массив указателей", а не указатель на массив! Если нам нужно определить указатель на массив, то следует использовать круглые скобки при описании:
int (*x)[10];
Здесь к имени x сначала применяется операция * определения указателя;
int f();
Описан прототип функции f без аргументов, возвращающей значение типа int.
int (*f())[10];
Описан прототип функции f без аргументов, возвращающей значение типа указатель на массив из 10 элементов типа int;
void (*a[100])(int x);
Описывается переменная a. К ней сначала применяется операция описания массива из 100 элементов, далее - определение указателя, далее - функция от одного целочисленного аргумента x типа int, наконец - определение возвращаемого типа int. Описание читается следующим образом:
Ниже расставлены номера операций в порядке их применения в описании переменной a:
void (* a [100])(int x);
5) 3) 1) 2) 4)
Специального типа данных строка в Си нет. Строки представляются массивами символов (а символы - их числовыми кодами, см. раздел 1.4.3). Последним символом массива, представляющего строку, должен быть символ с нулевым кодом. Пример:
char str[10];
str[0] = 'e'; str[1] = '2';
str[2] = 'e'; str[3] = '4';
str[4] = 0;
Описан массив str из 10 символов, который может представлять строку длиной не более 9, поскольку один элемент должен быть зарезервирован для терминирующего нуля. Далее в массив str записывается строка "e2e4". Строка терминируется нулевым символом. Всего запись строки использует 5 первых элементов массива str с индексами 0...4. Последние 5 элементов массива не используются. Массив можно инициализировать непосредственно при описании, например
char t[] = "abc";
Здесь мы не указываем в квадратных скобках размер массива t, компилятор его вычисляет сам. После операции присваивания записана строковая константа "abc", которая заносится в массив t. В результате компилятор создает массив t из четырех элементов, поскольку на строку отводится 4 байта, включая терминирующий ноль.
Строковые константы заключаются в Си в двойные апострофы, в отличие от символьных, которые заключаются в одинарные. Значением строковой константы является адрес ее первого символа. Когда компилятор встречает строковую константу в программе, он записывает ее текст в область статической памяти, обычно защищенную от изменения, и использует этот адрес. Например, в результате следующего описания
const char *s = "abcd";
создается указатель s, а также строка символов "abcd", строка помещается в область статической памяти, защищенную от изменения, а в указатель s помещается адрес начала строки. Строка содержит 5 элементов: коды символов abcd и терминирующий нулевой байт.
Константы в Си можно задавать двумя способами:
#define MILLENIUM 1000
задает символическое имя MILLENIUM для константы 1000. Препроцессор всюду в тексте заменяет это имя на константу 1000, используя текстовую подстановку. Это не очень хороший способ, поскольку при таком задании отсутствует контроль типов;
const int MILLENIUM = 1000;
Модификатор const означает, что переменная MILLENIUM является константой, т.е. менять ее значение нельзя. Попытка присвоить новое значение константе приведет к ошибке компиляции:
MILLENIUM = 100; // Ошибка: константу
// нельзя изменять
При описании указателя модификатор const, записанный до звездочки, означает, что описан указатель на константный объект, т.е. на объект, менять который нельзя или запрещено. Например, в строке
const char *p;
описан указатель на константную строку (массив символов, менять который запрещено).
Указатели на константные объекты используются в Си чрезвычайно часто. Причина состоит в том, что константный указатель позволяет прочесть объект и при этом гарантирует, что объект не будет испорчен в результате ошибки программирования, т.к. константный указатель не дает возможности изменить объект.
Константный указатель ссылается на константный объект, однако, содержимое самого указателя может изменяться. Например, следующий фрагмент вполне корректен:
const char *str = "e2e4";
. . .
str = "c7c5";
Здесь константный указатель str сначала содержит адрес константной строки "e2e4". Затем в него записывается адрес другой константной строки "c7c5".
В Си можно также описать указатель, значение которого не может быть изменено; для этого модификатор const указывается после звездочки. Например, фрагмент кода
int i;
int * const p = &i;
навечно записывает в указатель p адрес переменной i, перенаправить указатель p на другую переменную уже нельзя. Строка
p = &n;
является ошибкой, т.к. указатель p - константа, а константе нельзя присвоить новое значение. Указатели, значения которых изменять нельзя, используются в Си значительно реже, в основном при заполнении константных таблиц.
Слово volatile в переводе означает "изменчивый, непостоянный". В Си к описанию переменной следует добавлять слово volatile, если ее значение может изменяться не в результате выполнения программы, а из-за каких-либо внешних событий. Например, переменная может измениться при выполнении программы-обработчика аппаратного прерывания (см. раздел 2.5). Другой причиной "внезапного" изменения значения переменной может быть переключение между нитями при параллельном программировании (см. 2.6.2) и модификация переменной в параллельной нити.
Необходимо обязательно сообщать компилятору о таких изменчивых переменных. Дело в том, что процессор выполняет все действия с регистрами, а не с элементами памяти. Оптимизирующий компилятор держит значения большинства переменных в регистрах, сводя к минимуму обращения к памяти. Непостоянная переменная может изменить свое значение в памяти, но программа будет по-прежнему использовать значение в регистре, которое осталось прежним. Из-за этого выполнение программы нарушится. Модификатор volatile запрещает даже временно помещать переменную в регистр процессора.
Пример описания переменной:
volatile int inputPort;
Здесь мы описываем целочисленную переменную inputPort и сообщаем компилятору, что ее значение может внезапно меняться в результате каких-либо внешних событий. Этим мы запрещаем компилятору помещать переменную в регистр процессора в целях оптимизации программы.
В языке Си можно задать имя типа, если его описание достаточно громоздко и его не хочется повторять много раз. В дальнейшем можно использовать имя типа при описании переменных. Для определения типа применяется оператор typedef. Синтаксически оператор typedef аналогичен обычному описанию переменной, к которому в самом начале добавлено слово typedef. При этом вместо переменной определяется имя нового типа. Сравните следующее описание переменной "real" и определение нового типа "Real":
double real; // Описание переменной real
typedef double Real; // Определение нового типа Real,
// эквивалентного типу double.
Мы как бы описываем переменную, добавляя к описанию слово typedef. При этом описываемое имя становится именем нового типа. Его можно использовать затем для задания переменных:
Real x, y, z;
Чаще всего определение типов с помощью typedef используют, когда описание типа достаточно громоздко. Оператор typedef позволяет задать его только один раз, что облегчает исправление программы при необходимости. Например, следующая строка определяет тип callback как указатель на функцию с одним целым параметром, возвращающую значение логического типа:
typedef bool (*callback)(int);
Строка, описывающая три переменные p, g, r,
callback p, q, r;
эквивалентна строке
bool (*p)(int), (*q)(int), (*r)(int);
но первая строка, конечно, понятнее и нагляднее.
Еще одна цель использования оператора typedef состоит в том, чтобы сделать текст программы менее зависимым от особенностей конкретной архитектуры (разрядности процессора, конкретного Си-компилятора и т.п.). Например, в старых Си-компиляторах, которые использовались для 16-разрядных процессоров Intel 80286, существовали так называемые близкие (near) и далекие (far) указатели. В эталонном языке Си ключевых слов near и far нет, они использовались лишь в Си-компиляторах для Intel 80286 как расширение языка. Поэтому, чтобы тексты программ не зависели от компилятора, в системных h-файлах с помощью оператора typedef определялись имена для типов указателей, а в текстах программ использовались не типы эталонного языка Си, а введенные имена типов. Например, тип "далекий указатель на константную строку" в соответствии с соглашениями фирмы Microsoft называется LPCTSTR (Long Pointer to Constant Text STRing). При использовании 16-разрядного компилятора он определяется в системных h-файлах как
typedef const char far *LPCTSTR;
в 32-разрядной архитектуре он определяется без ключевого слова far (поскольку в ней все указатели "далекие"):
typedef const char *LPCTSTR;
Во всех программах указатели на константные строки описываются как имеющие тип LPCTSTR:
LPCTSTR s;
благодаря этому программы Microsoft можно использовать как в 16-разрядной, так и в 32-разрядной архитектуре.
Выражения в Си составляются из переменных или констант, к которым применяются различные операции. Для указания порядка операций можно использовать круглые скобки.
Отметим, что, помимо обычных операций, таких, как сложение или умножение, в Си существует ряд операций, несколько непривычных для начинающих. Например, запятая и знак равенства (оператор присваивания) являются операциями в Си; помимо операции сложения +, есть еще операция увеличить на += и операция увеличения на единицу ++. Зачастую они позволяют писать эстетически красивые, но не очень понятные для начинающих программы.
Впрочем, эти непривычные операции можно не использовать, заменяя их традиционными.
Оператор присваивания является основой любого алгоритмического языка (см. лекцию 3). В Си он записывается с помощью символа равенства, например, строка
x = 100;
означает присвоение переменной x значения 100. Для сравнения двух значений используется двойное равенство ==, например, строка
bool f = (2 + 2 == 5);
присваивает логической переменной f значение false (поскольку 2+2 не равно пяти, логическое выражение в скобках ложно).
Непривычным для начинающих может быть то, что оператор присваивания "=" в Си - бинарная операция, такая же, как, например, сложение или умножение. Значением операции присваивания = является значение, которое присваивается переменной, стоящей в левой части. Это позволяет использовать знак присваивания внутри выражения, например,
x = (y = sin(z)) + 1.0;
Здесь в скобках стоит выражение y = sin(z), в результате вычисления которого переменной y присваивается значение sin z. Значением этого выражения является значение, присвоенное переменной y, т.е. sin z. К этому значению затем прибавляется единица, т.е. в результате переменной x присваивается значение sin z+1.
Выражения, подобные приведенному в этом примере, иногда используются, когда необходимо запомнить значение подвыражения (в данном случае sin (z)) в некоторой переменной (в данном случае y), чтобы затем не вычислять его повторно. Еще один пример:
n = (k = 3) + 2;
В результате переменной k присваивается значение 3, а переменной n - значение 5. Конечно, в нормальных программах такие выражения не встречаются.
К четырем обычным арифметическим операциям сложения +, вычитания -, умножения * и деления / в Си добавлена операция нахождения остатка от деления первого целого числа на второе, которая обозначается символом процента %. Приоритет у операции вычисления остатка % такой же, как и у деления или умножения. Отметим, что операция % перестановочна с операцией изменения знака (унарным минусом), например, в результате выполнения двух строк
x = -(5 % 3);
y = (-5) % 3;
обеим переменным x и y присваивается отрицательное значение -2.
В Си добавлены операции увеличения и уменьшения на единицу, которые, к примеру, очень удобно применять к счетчикам. Операция увеличения записывается с помощью двух знаков сложения ++, операция уменьшения - с помощью двух минусов --. Например, операция ++, примененная к целочисленной переменной i, увеличивает ее значение на единицу:
++i; эквивалентно i = i+1
Операции увеличения и уменьшения на единицу можно применять только к дискретным типам - целочисленным переменным различного вида и указателям. Операцию нельзя применять к вещественным переменным! Например, следующий фрагмент программы является ошибочным:
double x;
. . .
++x; // Ошибка! Операция ++ неприменима
// к вещ. переменной
Операция ++ увеличивает значение переменной на "минимальный атом". Так как для вещественных переменных такого "атомарного" значения нет, операции увеличения и уменьшения для них запрещены.
Для указателей операция ++ увеличивает значение переменной на размер одного элемента того типа, на который ссылается указатель. Для указателя "атомом" является один элемент заданного типа, поэтому размер одного элемента и является шагом изменения значения указателя. Это очень естественно, т.к. после увеличения указатель будет содержать адрес следующего элемента данного типа, а после уменьшения - адрес предыдущего элемента. Пример:
double a[100];
double *p = &(a[15]); // в p записывается адрес
// элемента массива a[15]
++p; // в p будет адрес элемента a[16]
// (адрес увеличивается на sizeof(double) == 8)
Описаны массив a вещественных чисел типа double и указатель p на элементы типа double. При описании указателя p в него заносится начальное значение, равное адресу элемента a[15] массива a. После выполнения операции увеличения ++ в переменной p будет содержаться адрес следующего элемента a[16]. Физически содержимое переменной p увеличивается на размер одного элемента типа double, т.е. на 8.
Операции увеличения ++ и уменьшения -- на единицу имеют префиксную и суффиксную формы. В префиксной форме операция записывается перед переменной, как в приведенных выше примерах. В суффиксной форме операция записывается после переменной:
++x; // Префиксная форма
x--; // Суффиксная форма
Разница между префиксной и суффиксной формами проявляется только при вычислении сложных выражений. Если используется префиксная форма операции ++, то сначала переменная увеличивается, и только после этого ее новое значение используется в выражении. При использовании суффиксной формы значение переменной сначала используется в выражении и только затем увеличивается. Примеры:
int x = 5, y = 5, a, b;
a = (++x) + 2; // переменной a присваивается значение 8
b = (y++) + 2; // переменной b присваивается значение 7
С логической точки зрения, префиксная операция более естественна (при использовании суффиксной формы надо сперва вычислить сложное выражение и только затем вернуться к увеличению переменной, т.е. операция ++ выполняется не в момент ее использования, а как бы откладывается на потом). Забегая вперед, отметим, что это различие весьма существенно при программировании на C++ в случае переопределения операторов увеличения для классов. Тем не менее, в большинстве книг по Си суффиксная форма используется чаще (скорее всего, эта традиция, связаная с эстетикой текста).
Дадим два совета (возможно, не бесспорные) по использованию операций ++ и --:
double *p, x, y;
. . .
y = *p++ + x;
лучше использовать фрагмент
double *p, x, y;
. . .
y = *p + x;
++p;
С точки зрения компилятора, они абсолютно эквивалентны, но второй фрагмент проще и понятнее (и, значит, вероятность ошибки программирования меньше);
int x, y;
. . .
x++; y--; // Используется суффиксная форма
лучше использовать фрагмент
int x, y;
. . .
++x; --y; // Лучше применять префиксную форму
В большинстве алгоритмов при выполнении операции сложения чаще всего переменная-результат операции совпадает с первым аргументом:
x = x + y;
Здесь складываются значения двух переменных x и y, результат помещается в первую переменную x. Таким образом, значение переменной x увеличивается на значение y. Подобные фрагменты встречаются в программах гораздо чаще, чем фрагменты вида
x = y + z;
где аргументы и результат различны. Рассмотрим, например, фрагмент программы, вычисляющий сумму элементов массива вещественных чисел (забегая вперед, мы используем в нем конструкцию цикла "пока"):
double a[100];
double s;
int i;
. . .
s = 0.0;
i = 0;
while (i < 100) {
s = s + a[i];
++i;
}
Здесь сумма элементов массива накапливается в переменной s. В строке
s = s + a[i];
к сумме s прибавляется очередной элемент массива a[i], т.е. значение s увеличивается на a[i]. В Си существует сокращенная запись операции увеличения:
s += a[i];
Оператор += читается как "увеличить на". Строка
x += y; // Увеличить значение x на y
эквивалентна в Си строке
x = x + y; // x присвоить значение x + y,
но короче и нагляднее.
Оператор вида ?= существует для любой операции ?, допустимой в Си. Например, для арифметических операций +, -, *, /, % можно использовать операции
+= увеличить на
-= уменьшить на
*= домножить на
/= поделить на
%= поделить с остатком на
к примеру, строка
x *= 2.0;
удваивает значение вещественной переменной x.
Операторы вида ?= можно использовать даже для операций ?, которые записываются двумя символами. Например, операции логического умножения и сложения (см. раздел 1.4.4) записываются в Си как && (двойной амперсенд) и || (двойная вертикальная черта). Соответственно, логические операторы "домножить на" и "увеличить на" записываются в виде &&= и ||=, например,
bool x, y;
x &&= y; // эквивалентно x = x && y;
x ||= y; // эквивалентно x = x || y;
Логические операции и выражения были подробно рассмотрены в разделе 1.4.4. В Си используются следующие обозначения для логических операций:
|| логическое "или" (логическое сложение)
&& логическое "и" (логическое умножение)
! логическое "не" (логическое отрицание)
Логические константы "истина" и "ложь" обозначаются через true и false (это ключевые слова языка). Примеры логических выражений:
bool a, b, c, d;
int x, y;
a = b || c; // логическое "или"
d = b && c; // логическое "и"
a = !b; // логическое "не"
a = (x == y); // сравнение в правой части
a = false; // ложь
b = true; // истина
c = (x > 0 && y != 1); // c истинно, когда
// оба сравнения истинны
Самый высокий приоритет у операции логического отрицания, затем следует логическое умножение, самый низкий приоритет у логического сложения.
Чрезвычайно важной особенностью операций логического сложения и умножения является так называемое "сокращенное вычисление" результата. А именно, при вычислении результата операции логического сложения или умножения всегда сначала вычисляется значение первого аргумента. Если оно истинно в случае логического сложения или ложно в случае логического умножения, то второй аргумент операции не вычисляется вовсе! Результат операции полагается истинным в случае логического сложения или ложным в случае логического умножения. Подробно это рассмотрено в разделе 1.4.4.
Операция сравнения сравнивает два выражения. В результате вырабатывается логическое значение - true или false (истина или ложь) в зависимости от значений выражений. Примеры:
bool res;
int x, y;
res = (x == y); // true, если x равно y, иначе false
res = (x == x); // всегда true
res = (2 < 1); // всегда false
Операции сравнения в Си обозначаются следующим образом:
== равно, != не равно,
> больше, >= больше или равно,
< меньше, <= меньше или равно.
Кроме обычных логических операций, в Си имеются побитовые логические операции, которые выполняются независимо для каждого отдельного бита операндов. Побитовые операции имеют следующие обозначения:
& побитовое логическое сложение ("и")
| побитовое логическое умножение ("или")
~ побитовое логическое отрицание ("не")
^ побитовое сложение по модулю 2 (исключающее "или")
(Необходимо помнить, что логические операции умножения и сложения записываются с помощью двойных знаков && или ||, а побитовые - с помощью одинарных.)
Ни в коем случае не используйте побитовые операции в качестве логических условий, это может приводить к непредсказуемым ошибкам!
В основном побитовые операции применяются для манипуляций с битовыми масками. Например, пусть целое число x описывает набор признаков некоторого объекта, состоящий из четырех признаков. Назовем их условно A, B, C, D. Пусть за признак A отвечает нулевой бит слова x (биты в двоичном представлении числа нумеруются справа налево, начиная с нуля). Если бит равен единице (программисты говорят бит установлен), то считается, что объект обладает признаком A. За признаки B, C, D отвечают биты с номерами 1, 2, 3. Общепринятая практика состоит в том, чтобы определить константы, отвечающие за соответствующие признаки (их обычно называют масками):
const int MASK_A = 1;
const int MASK_B = 2;
const int MASK_C = 4;
const int MASK_D = 8;
Эти константы содержат единицу в соответствующем бите и нули в остальных битах. Для того чтобы проверить, установлен ли в слове x бит, соответствующий, к примеру, признаку D, используется операция побитового логического умножения. Число x умножается на константу MASK_D; если результат отличен от нуля, то бит установлен, т.е. объект обладает признаком D, если нет, то не обладает. Такая проверка реализуется следующим фрагментом:
if ((x & MASK_D) != 0) {
// Бит D установлен в слове x, т.е.
// объект обладает признаком D
. . .
} else {
// Объект не обладает признаком D
. . .
}
При побитовом логическом умножении константа MASK_D обнуляет все биты слова x, кроме бита D, т.е. как бы вырезает бит D из x. В двоичном представлении это выглядит примерно так:
x: 0101110110...10*101
MASK_D: 0000000000...001000
x & MASK_D: 0000000000...00*000
Звездочкой здесь обозначено произвольное значение бита D слова x.
Для установки бита D в слове x используется операция побитового логического сложения:
x = (x | MASK_D); // Установить бит D в слове x
Чаще это записывается с использованием операции |= типа "увеличить на" (см. раздел 3.4.4):
x |= MASK_D; // Установить бит D в слове x
В двоичном виде это выглядит так:
x: 0101110110...10*101
MASK_D: 0000000000...001000
x | MASK_D: 0101110110...101101
Операция побитового отрицания "~" инвертирует биты слова:
x: 0101110110...101101
~x: 1010001001...010010
Для очистки (т.е. установки в ноль) бита D используется комбинация операций побитового отрицания и побитового логического умножения:
x = (x & ~MASK_D); // Очистить бит D в слове x
или, применяя операцию "&=" типа "домножить на":
x &= ~MASK_D; // Очистить бит D в слове x
Здесь сначала инвертируется маска, соответствующая биту D,
MASK_D: 0000000000...001000
~MASK_D: 1111111111...110111
в результате получаются единицы во всех битах, кроме бита D. Затем слово x побитно домножается на инвертированную маску:
x: 0101110110...10*101
~MASK_D: 1111111111...110111
x & ~MASK_D: 0101110110...100101
В результате в слове x бит D обнуляется, а остальные биты остаются неизменными.
Приоритеты побитовых операций в Си выбраны достаточно странно (они такие же, как у соответствующих логических операций), это иногда приводит к неожиданным ошибкам. Например, если не заключить в скобки операцию побитового умножения в приведенном выше примере, то получится ошибочный результат: строка
if (x & MASK_D != 0) {
эквивалентна строке
if ((x & 1) != 0) {
т.е. проверяется бит A, а вовсе не D! Дело в том, что приоритет операции сравнения != выше, чем операции побитового умножения &, т.е. в приведенной строке скобки неявно расставлены так:
if (x & (MASK_D != 0)) {
Выражение (MASK_D != 0) истинно и, таким образом, равно единице, поэтому строка эквивалентна
if (x & 1) {
что, в свою очередь, эквивалентно более канонической записи:
if ((x & 1) != 0) {
Чтобы избежать подобных ошибок, всегда заключайте все побитовые операции в скобки.
Побитовую операцию ^ называют сложением по модулю 2, а также "исключающим или". Часто для нее используется аббревиатура XOR, от eXclusive OR. "Таблица сложения" для этой операции выглядит следующим образом:
0 ^ 0 = 0, 0 ^ 1 = 1,
1 ^ 0 = 1, 1 ^ 1 = 0.
Пусть x - произвольное целое число, m - маска, т.е. число, в котором интересующие программиста биты установлены в единицу, остальные в ноль. В результате выполнения операции XOR
x = (x ^ m);
или, в более удобной записи,
x ^= m;
биты в слове x, соответствующие установленным в единицу битам маски m, изменяются на противоположные (инвертируются). Биты слова x, соответствующие нулевым битам маски, не меняют своих значений. Пример:
x: 101101...1001011110
m: 000000...0011111100
x ^ m: 101101...1010100010
Операция XOR обладает замечательным свойством: если дважды прибавить к слову x произвольную маску m, то в результате получается исходное значение x:
((x ^ m) ^ m) == x
Прибавление к слову x маски m можно трактовать как шифрование x, ведь в результате биты x, соответсвующие единичным битам маски m, инвертируются. Если маска достаточно случайная, то в результате x тоже принимает случайное значение. Процедура расшифровки в данном случае совпадает с процедурой шифрования и состоит в повторном прибавлении маски m.
Оперции сдвига применяются к целочисленным переменным: двоичный код числа сдвигается вправо или влево на указанное количество позиций. Сдвиг вправо обозначается двумя символами "больше" >>, сдвиг влево - двумя символами "меньше" <<. Примеры:
int x, y;
. . .
x = (y >> 3); // Сдвиг на 3 позиции вправо
y = (y << 2); // Сдвиг на 2 позиции влево
При сдвиге влево на k позиций младшие k разрядов результата устанавливаются в ноль. Сдвиг влево на k позиций эквивалентен умножению на число 2k. Сдвиг вправо более сложен, он по-разному определяется для беззнаковых и знаковых чисел. При сдвиге вправо беззнакового числа на k позиций освободившиеся k старших разрядов устанавливаются в ноль. Например, в двоичной записи имеем:
unsigned x;
x = 110111000...10110011
x >> 3 = 000110111000...10110
Сдвиг вправо на k позиций соответствует целочисленному делению на число 2k.
При сдвиге вправо чисел со знаком происходит так называемое "расширение знакового разряда". Именно, если число неотрицательно, т.е. старший, или знаковый, разряд числа равен нулю, то происходит обычный сдвиг, как и в случае беззнаковых чисел. Если же число отрицательное, т.е. его старший разряд равен единице, то освободившиеся в результате сдвига k старших разрядов устанавливаются в единицу. Число, таким образом, остается отрицательным. При k = 1 это соответствует делению на 2 только для отрицательных чисел, не равных -1. Для числа -1, все биты двоичного кода которого равны единице, сдвиг вправо не приводит к его изменению. Пример (используется двоичная запись):
int x;
x = 110111000...10110011
x >> 3 = 111110111000...10110
В программах лучше не полагаться на эту особенность сдвига вправо для знаковых чисел и использовать конструкции, которые заведомо одинаково работают для знаковых и беззнаковых чисел. Например, следующий фрагмент кода выделяет из целого числа составляющие его байты и записывает их в целочисленные переменные x0, x1, x2, x3, младший байт в x0, старший в x3. При этом байты трактуются как неотрицательные числа. Фрагмент выполняется одинаково для знаковых и беззнаковых чисел:
int x;
int x0, x1, x2, x3;
. . .
x0 = (x & 255);
x1 = ((x >> 8) & 255);
x2 = ((x >> 16) & 255);
x3 = ((x >> 24) & 255);
Здесь число 255 играет роль маски, см. раздел 3.4.7. При побитовом умножении на эту маску из целого числа вырезается его младший байт, поскольку маска 255 содержит единицы в младших восьми разрядах. Чтобы получить байт числа x с номером n, n = 0,1,2,3, мы сначала сдвигаем двоичный код x вправо на 8n разрядов, таким образом, байт с номером n становится младшим. Затем с помощью побитового умножения вырезается младший байт.
С указателями можно выполнять следующие операции:
Прибавление к указателю p целого числа n означает увеличение адреса, который содержится в переменной p, на суммарный размер n элементов того типа, на который ссылается указатель. Указатель как бы сдвигается на n элементов вправо, если считать, что индексы элементов массива возрастают слева направо. Аналогично вычитание целого числа n из указателя означает сдвиг указателя влево на n элементов. Пример:
int *p, *q;
int a[100];
p = &(a[5]); // записываем в p адрес 5-го
// элемента массива a
p += 7; // p будет содержать адрес 12-го эл-та
q = &(a[10]);
--q; // q содержит адрес элемента a[9]
Значение указателя при прибавлении к нему целого числа n увеличивается на произведение n на количество байтов, занимаемое одним элементом того типа, на который ссылается указатель. В программировании это называют масштабированием.
Разность двух указателей - это количество элементов данного типа, которое умещается между двумя адресами. Результатом вычитания указателей является целое число. Физически оно вычисляется как разность значений двух адресов, деленная на размер одного элемента заданного типа. Операции сложения указателя с целым числом и разности двух указателей взаимно обратны:
int *p, *q;
int a[100];
int n;
p = &(a[5]);
q = &(a[12]);
n = q - p; // n == 7
q = p + n; // q == &(a[12])
Подчеркнем, что указатели нельзя складывать! В отличие от разности указателей, операция сложения указателей (т.е. сложения адресов памяти) абсолютно бессмысленна.
int *p, *q, *r;
int a[100];
p = &(a[5]);
q = &(a[12]);
r = p + q; // Ошибка! Указатели нельзя складывать.
В языке Си имя массива a является указателем на его первый элемент, т.е. выражения a и &(a[0]) эквивалентны. Учитывая арифметику указателей, получаем эквивалентность следующих выражений:
a[i] ~ *(a+i)
Действительно, при прибавлении к a целого числа i происходит сдвиг на i элементов вправо. Поскольку имя массива является адресом его начального элемента, получается адрес i-го элемента массива a. Применяя операцию звездочка *, получаем сам элемент a[i]. Точно так же эквивалентны выражения
&(a[i]) ~ a+i (адрес эл-та a[i]).
Эта особенность арифметики указателей позволяет вообще не использовать квадратные скобки, т.е. обращение к элементу массива; вместо этого можно использовать указатели и операцию звездочка *.
Обратно, пусть p - указатель. Синтаксис языка Си позволяет трактовать его как адрес начала массива и применять к нему операцию доступа к элементу массива с заданным индексом. Эквивалентны следующие выражения:
p[i] ~ *(p+i)
Таким образом, выбор между массивами и указателями - это выбор между двумя эквивалентными способами записи программ. Указатели, возможно, нравятся системным программистам, которые привыкли к работе с адресами объектов. Массивы больше отвечают традиционному стилю. В объектно-ориентированных языках, таких как Java или C#, указателей либо нет вовсе, либо их разрешено использовать лишь в специфических ситуациях. Массивы же присутствуют в подавляющем большинстве алгоритмических языков.
Для иллюстрации работы с массивами и с указателями приведем два фрагмента программы, суммирующие элементы массива.
double a[100], s; int i; ... s = 0.0; i = 0 while (i < 100) {
s += a[i]; ++i; } |
double a[100], s; double *p, *g; ... s = 0.0; p = a; // адрес начала массива g = a+100; // адрес за концом while (p < g) {
s += *p; ++p; } |
Операция приведения типа (type cast) является одной из самых важных в Си. Без знакомства с синтаксисом этой операции (весьма непривычного для начинающих) и сознательного ее использования написать на Си что-нибудь более или менее полезное невозможно.
Операция приведения типа используется, когда значение одного типа преобразуется к другому типу, в том случае, если существует некоторый разумный способ такого преобразования. Операция обозначается именем типа, заключенным в круглые скобки; она записывается перед ее единственным аргументом. Рассмотрим два примера. Пусть требуется преобразовать целое число к вещественному типу. Как известно, целые и вещественные числа по-разному представляются в компьютере, см. раздел 3.3.1. Тем не менее, существует однозначный способ преобразования целого числа типа int к вещественному типу double. В первом примере значение целой переменной n приводится к вещественному типу и присваивается вещественной переменной x:
double x;
int n;
. . .
x = (double) n; // Операция приведения к типу double
В данном случае никакой потери информации не происходит, поэтому такое приведение допустимо и по умолчанию:
x = n; // Эквивалентно x = (double) n;
Во втором примере вещественное значение преобразуется к целому типу. При этом дробная часть вещественного числа отбрасывается, а знак числа сохраняется:
double x, y;
int n, k;
. . .
x = 3.7;
y = (-1.5);
n = (int) x; // n присваивается значение 3
k = (int) y; // k присваивается значение -1
В результате выполнения операции приведения вещественного числа к целому типу происходит отбрасывание дробной части числа, т.е. потеря информации. Поэтому, если использовать операцию приведения типа неявно (т.е. в результате простого присваивания целой переменной вещественного значения), например,
double x; int n;
. . .
n = x; // неявное приведение вещественного к целому
то компилятор обязательно выдаст предупреждение (или даже ошибку, если компилятор строгий). Поэтому так писать ни в коем случае нельзя! Когда используется явное приведение типа, компилятору сообщается, что это не случайная ошибка, а намеренное приведение вещественного значения к целому типу, при котором дробная часть отбрасывается. При этом компилятор никаких предупреждений не выдает.
Операция приведения типа чаще всего используется для преобразования указателей. Например, стандартная функция захвата динамической памяти malloc возвращает указатель общего типа void* (см. раздел 3.7.3). Значение указателя обобщенного типа нельзя присвоить указателю на конкретный тип (язык C++ запрещает такие присвоения, Си-компиляторы иногда разрешают преобразования указателей по умолчанию, выдавая предупреждения, - но в любом случае это дурной стиль!). Для преобразования указателей разного типа нужно использовать операцию приведения типа в явном виде. В следующем примере в динамической памяти захватывается участок размером в 400 байт, его адрес присваивается указателю на массив из 100 целых чисел:
int *a; // Описываем указатель на массив типа int
. . .
// Захватываем участок памяти размером в 400 байт
// (поскольку sizeof(int) == 4), приводим указатель
// на него от типа void* к типу int* и присваиваем
// приведенное значение указателю a:
a = (int*) malloc(100 * sizeof(int));
Отметим, что допустимо неявное преобразование любого указателя к указателю обобщенного типа void*. Обратное, как указано выше, считается грубой ошибкой в C++ и дурным стилем (возможно, сопровождаемым предупреждением компилятора) в Си:
int *a; // Указатель на целое число
void *p; // Указатель обобщенного типа
. . .
a = p; // Ошибка! В C++ запрещено неявное
// приведение типа от void* к int*
a = (int*) p; // Корректно: явное приведение типа
p = a; // Корректно: любой указатель можно
// неявно привести к обобщенному
![]()