учебники, программирование, основы, введение в,

 

Основы языка Си: структура Си-программы, базовые типы и конструирование новых типов, операции и выражения

Основы языка Си
В настоящее время язык Си и объектно-ориентированные языки его группы (прежде всего C++, а также Java и C#) являются основными в практическом программировании. Достоинство языка Си - это, прежде всего, его простота и лаконичность. Язык Си легко учится. Главные понятия языка Си, такие, как статические и локальные переменные, массивы, указатели, функции и т.д., максимально приближены к архитектуре реальных компьютеров. Так, указатель - это просто адрес памяти, массив - непрерывная область памяти, локальные переменные - это переменные, расположенные в аппаратном стеке, статические - в статической памяти. Программист, пишущий на Си, всегда достаточно точно представляет себе, как созданная им программа будет работать на любой конкретной архитектуре. Другими словами, язык Си предоставляет программисту полный контроль над компьютером.
Первоначально язык Си задумывался как заменитель Ассемблера для написания операционных систем. Поскольку Си - это язык высокого уровня, не зависящий от конкретной архитектуры, текст операционной системы оказывался легко переносимым с одной платформы на другую. Первой операционной системой, написанной практически целиком на Си, была система Unix. В настоящее время почти все используемые операционные системы написаны на Си. Кро- ме того, средства программирования, которые операционная система предоставляет разработчикам прикладных программ (так называемый API - Application Program Interface), - это наборы системных функций на языке Си.
Тем не менее, область применения языка Си не ограничилась разработкой операционных систем. Язык Си оказался очень удобен в программах обработки текстов и изображений, в научных и инженерных расчетах. Объектно-ориентированные языки на основе Си отлично подходят для программирования в оконных средах.
В данном разделе будут приведены лишь основные понятия языка Си (и частично C++). Это не заменяет чтения полного учебника по Си или C++, например, книг [6] и [8].
Мы будем использовать компилятор C++ вместо Cи. Дело в том, что язык Си почти целиком входит в C++, т.е. нормальная программа, написанная на Си, является корректной C++ программой. Слово "нормальная" означает, что она не содержит неудачных конструкций, оставшихся от ранних версий Си и не используемых в настоящее время. Компилятор C++ предпочтительнее, чем компилятор Си, т.к. он имеет более строгий контроль ошибок. Кроме того, некоторые конструкции C++, не связанные с объектно-ориентированным программированием, очень удобны и фактически являются улучшением языка Си. Это, прежде всего, комментарии //, возможность описывать локальные переменные в любой точке программы, а не только в начале блока, и также задание констант без использования оператора #define препроцесора. Мы будем использовать эти возможности C++, оставаясь по существу в рамках языка Си.
Структура Си-программы
Любая достаточно большая программа на Си (программисты используют термин проект) состоит из файлов. Файлы транслируются Си-компилятором независимо друг от друга и затем объединяются программой-построителем задач, в результате чего создается файл с программой, готовой к выполнению. Файлы, содержащие тексты Си-программы, называются исходными.
В языке Си исходные файлы бывают двух типов:

  • заголовочные, или h-файлы;
  • файлы реализации, или Cи-файлы.

Имена заголовочных файлов имеют расширение ".h". Имена файлов реализации имеют расширения ".c" для языка Си и ".cpp", ".cxx" или ".cc" для языка C++.
К сожалению, в отличие от языка Си, программисты не сумели договориться о едином расширении имен для файлов, содержащих программы на C++. Мы будем использовать расширение ".h" для заголовочных файлов и расширение ".cpp" для файлов реализации.
Заголовочные файлы содержат только описания. Прежде всего, это прототипы функций. Прототип функции описывает имя функции, тип возвращаемого значения, число и типы ее аргументов. Сам текст функции в h-файле не содержится. Также в h-файлах описываются имена и типы внешних переменных, константы, новые типы, структуры и т.п. В общем, h-файлы содержат лишь интерфейсы, т.е. информацию, необходимую для использования программ, уже написанных другими программистами (или тем же программистом раньше). Заголовочные файлы лишь сообщают информацию о других программах. При трансляции заголовочных файлов, как правило, никакие объекты не создаются. Например, в заголовочном файле нельзя определить глобальную переменную. Строка описания
int x;
определяющая целочисленную переменную x, является ошибкой. Вместо этого следует использовать описание

extern int x;
означающее, что переменная x определена где-то в файле реализации (в каком - неизвестно). Слово extern (внешняя) лишь сообщает информацию о внешней переменной, но не определяет эту переменную.
Файлы реализации, или Cи-файлы, содержат тексты функций и определения глобальных переменных. Говоря упрощенно, Си-файлы содержат сами программы, а h-файлы - лишь информацию о программах.
Представление исходных текстов в виде заголовочных файлов и файлов реализации необходимо для создания больших проектов, имеющих модульную структуру. Заголовочные файлы служат для передачи информации между модулями. Файлы реализации - это отдельные модули, которые разрабатываются и транслируются независимо друг от друга и объединяются при создании выполняемой программы.
Файлы реализации могут подключать описания, содержащиеся в заголовочных файлах. Сами заголовочные файлы также могут использовать другие заголовочные файлы. Заголовочный файл подключается с помощью директивы препроцессора #include. Например, описания стандартых функций ввода-вывода включаются с помощью строки

#include <stdio.h>
(stdio - от слов standard input/output). Имя h-файла записывается в угловых скобках, если этот h-файл является частью стандартной Си-библиотеки и расположен в одном из системных каталогов. Имена h-файлов, созданных самим программистом в рамках разрабатываемого проекта и расположенных в текущем каталоге, указываются в двойных кавычках, например,

#include "abcd.h"
Препроцессор - это программа предварительной обработки текста непосредственно перед трансляцией. Команды препроцессора называются директивами. Директивы препроцессора содержат символ диез # в начале строки. Препроцессор используется в основном для подключения h-файлов. В Си также очень часто используется директива #define для задания символических имен констант. Так, строка
#define PI 3.14159265
задает символическое имя PI для константы 3.14159265. После этого имя PI можно использовать вместо числового значения. Препроцессор находит все вхождения слова PI в текст и заменяет их на константу. Таким образом, препроцессор осуществляет подмену одного текста другим. Необходимость использования препроцессора всегда свидетельствует о недостаточной выразительности языка. Так, в любом Ассемблере средства препроцессирования используются довольно интенсивно. В Си по возможности следует избегать чрезмерного увлечения командами препроцессора - это затрудняет понимание текста программы и зачастую ведет к трудно исправляемым ошибкам. В C++ можно обойтись без использования директив #define для задания констант. Например, в C++ константу PI можно задать с помощью нормального описания
const double PI = 3.14159265;
Это является одним из аргументов в пользу применения компилятора C++ вместо Си даже при трансляции программ, не содержащих конструкции класса.

http://localhost:3232/img/empty.gifhttp://localhost:3232/img/empty.gifФункции

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

double sin(double x);

имеет один аргумент x типа double (вещественное число). Результат функции также имеет тип double. При вызове фукция sin вычисляет синус числа, переданного ей в качестве фактического аргумента, и возвращает вычисленное значение в вызывающую программу.
Вызов функции происходит в результате использования ее имени в выражении. За именем функции следуют круглые скобки, внутри которых перечисляются фактические значения ее аргументов. Даже если аргументов нет, круглые скобки с пустым списком аргументов обязательно должны присутствовать!
После вызова функции значение, возвращенное в результате ее выполнения, используется в выражении (имя функции как бы заменяется возвращенным значением). Примеры:

x = sin(1.0);

Здесь в результата вызова функции sin вычисляется синус числа 1.0, затем вычисленное значение записывается в переменную x при выполнения оператора присваивания "=". Другой пример:

    
f();

Вызывается функция f, не имеющая параметров. Значение, возвращенное в результате выполнения функции f, не используется. Программа на Си состоит из функций. Работа программы всегда начинается с функции с именем main. Рассмотрим минимальный пример Си-программы

Программа "Hello, World!"

Приведенная ниже программа печатает фразу "Hello, World!" на экране терминала.

 
    #include <stdio.h>
 
    int main() {
        printf("Hello, World\n");
        return 0;
    }

Первая строка подключает заголовочный файл с описаниями стандартных функций ввода-вывода Си-библиотеки. В частности, в этом файле описан прототип функции printf (печать по формату), используемой для вывода информации в стандартный поток вывода (по умолчанию он назначен на терминал). Выполнение программы начинается с функции main. Функция main возвращает по окончании работы целое число, которое трактуется операционной системой как код завершения задания. Число ноль обычно означает успешное выполнение задачи, но вообще-то программист волен по своему усмотрению определять коды завершения. Во многих книгах приводятся примеры функций main, которые ничего не возвращают, - строго говоря, это ошибка (на которую, к сожалению, многие компиляторы никак не реагируют).
Тело любой функции заключается в фигурные скобки. В теле функции main вызывается функция printf. В данном случае ее единственным аргументом является строка, которая выводится в стандартный поток вывода. Строковые константы в Си заключаются в двойные апострофы. Строка заканчивается символом перевода курсора в начало следующей строки \n (читается как "new line", новая строка). Желательно любую печать завершать этим символом, иначе при следующей печати новая строка будет дописана в конец предыдущей.
Строка

     
return 0;

завершает выполнение функции main и возвращает нулевой результат ее выполнения. Операционная система трактует нулевой результат как признак успешного завершения программы.
Для выполнения данной программы надо сначала ввести ее текст в файл "hello.cpp", используя любой текстовый редактор. Затем надо скомпилировать и собрать готовую программу. Конкретные команды зависят от операционной системы и установленного Си-компилятора. В системе Unix с компилятором gcc из пакета GNU это делается с помощью команды

     
g++ hello.cpp

В результате создается выполняемый файл с именем "a.out". Для запуска программы следует выполнить команду

     
./a.out

Если необходимо, чтобы в результате компиляции и сборки создавался выполняемый файл с именем "hello", то надо выполнить следующую команду:

 
g++ -o hello hello.cpp

Здесь в командной строке используется ключ "-o hello" (от слова "output"), задающий имя "hello" для выходного файла. В этом случае программа запускается с помощью команды

     
./hello

Заметим, что в системе Unix имена выполняемых файлов обычно не имеют никакого расширения. В системах MS DOS и MS Windows выполняемые файлы имеют расширение ".exe".

Типы переменных

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

Базовые типы

В языке Си используются всего два базовых типа: целые и вещественные числа. Кроме того, имеется фиктивный тип void ("пустота"), который применяется либо для функции, не возвращающей никакого значения, либо для описания указателя общего типа (когда неизвестна информации о типе объекта, на который ссылается указатель).
В C++ добавлен логический тип.

Целочисленные типы

Целочисленные типы различаются по длине в байтах и по наличию знака. Их четыре - char, short, int и long. Кроме того, к описанию можно добавлять модификаторы unsigned или signed для беззнаковых (неотрицательных) или знаковых целых чисел.

Тип int

Самый естественный целочисленный тип - это тип int, от слова integer - целое число. Тип int всегда соответствует размеру машинного слова или адреса. Все действия с элементами типа int производятся максимально быстро. Всегда следует выбирать именно тип int, если использование других целочисленных типов не диктуется явно спецификой решаемой задачи. Параметры большинства стандартных функций, работающих с целыми числами или символами, имеют тип int. Целочисленные типы были подробно рассмотрены в разделе . Подчеркнем еще раз, что целочисленные переменные хранят на самом деле не целые числа, а элементы кольца вычетов по модулю m, где m - степень двойки.
В современных архитектурах элемент типа int занимает 4 байта, т.е. m = 232. Элементы типа int трактуются в Си как числа со знаком. Минимальное отрицательное число равно -231 = -2147483648, максимальное положительное равно 231-1 = 2147483647.
При описании переменной сначала указывается базовый тип, затем - имя переменной или список имен, разделенных запятыми, например,

int x;
int y, z, t;

При описании переменных можно присваивать им начальные значения:

    
int maxind = 1000;
int a = 5, b = 7;

Кроме типа int, существуют еще три целочисленных типа: char, short и long.

Тип char

Тип char представляет целые числа в диапазоне от -128 до 127. Элементы типа char занимают один байт памяти. Слово "char" является сокращением от character, что в переводе означает "символ". Действительно, традиционно символы представляются их целочисленными кодами, а код символа занимает один байт (см. раздел ). Тем не менее, подчеркнем, что элементы типа char - это именно целые числа, с ними можно выполнять все арифметические операции. С математической точки зрения, элементы типа char - это элементы кольца вычетов m = Z256. Стандарт Си не устанавливает, трактуются ли элементы типа char как знаковые или беззнаковые числа, но большинство Си-компиляторов считают char знаковым типом. Примеры описаний переменных типа char:

char c;
char eof = (-1);
char letterA = 'A';

В последнем случае значение переменной "letterA" инициализируется кодом латинской буквы 'A', т.е. целым числом 65. В Си символьные константы записываются в одинарных апострофах и означают коды соответствующих символов в кодировке ASCII. Рассмотрим следующий пример:

    
char c = 0;
char d = '0';

Здесь переменная c инициализируется нулевым значением, а переменная d - значением 48, поскольку символ '0' имеет код 48.

Типы short и long

Слова short и long означают в Си короткое и длинное целое число со знаком. Стандарт Си не устанавливает конкретных размеров для типов short и long. В самой распространенной в настоящее время 32-разрядной архитектуре переменная типа short занимает 2 байта (диапазон значений - от -32768 до 32767), а тип long совпадает с типом int, размер его равен четырем байтам. Примеры описаний:

short s = 30000;
long x = 100000;
int y = 100000;

В 32-разрядной архитектуре переменные x и y имеют один и тот же тип.

Модификатор unsigned

Типы int, short и long представляют целые числа со знаком. Для типа char стандарт Си не устанавливает явно наличие знака, однако большинство компиляторов трактуют элементы типа char как целые числа со знаком в диапазоне от -128 до 127. Если необходимо трактовать целые числа как неотрицательные, или беззнаковые, следует добавить модификатор unsigned при описании переменных. Примеры:

unsigned char c = 255;
unsigned short s = 65535;
unsigned int i = 1000000000;
unsigned j = 1;

При описании типа "unsigned int" слово "int" можно опускать, что и сделано в последнем примере.
Следует по возможности избегать беззнаковых типов, поскольку арифметика беззнаковых чисел не на всех компьютерах реализована одинаково и из-за этого при переносе программы с одной платформы на другую могут возникнуть проблемы. По этой причине в языке Java беззнаковые числа запрещены.
Имеется также модификатор signed (знаковый). Его имеет смысл использовать на тех платформах, в которых тип char является беззнаковым. Пример описания:

signed char d = (-1);
Вещественные типы

Вещественных типов два: длинное вещественное число double (переводится как "двойная точность") и короткое вещественное число float (переводится как "плавающее"). Вещественные типы были подробно рассмотрены в разделе 1.4.2. Вещественное число типа double занимает 8 байтов, типа float - 4 байта.
Тип double является основным для компьютера. Тип float - это, скорее, атавизм, оставшийся от ранних версий языка Си. Компьютер умеет производить арифметические действия только с элементами типа double, элементы типа float приходится сначала преобразовывать к double. Точность, которую обеспечивает тип float, низка и не достаточна для большинства практических задач. Все стандартные функции математической библиотеки работают только с типом double. Рекомендуем вам никогда не использовать тип float!
Примеры описаний вещественных переменных:

double x, y, z;
double a = 1.5, b = 1e+6, c = 1.5e-3;

В последних двух случаях использовалось задание вещественных констант в экспоненциальной форме (см. раздел 1.4.2).

Логический тип

В языке Си специального логического типа нет, вместо него используются переменные целого типа. Значению "истина" соответствует любое ненулевое целое число, значению "ложь" - ноль. Например, в Си допустим такой фрагмент программы:

int b;
double s;
. . .
if (b) {
    s = 1.0;
}

Здесь целочисленная переменная b используется в качестве условного выражения в операторе if ("если"). Если значение b отлично от нуля, то выполняется тело оператора if, т.е. переменной s присваивается значение 1.0; если значение b равно нулю, то тело оператора if не выполняется.
На самом деле, приведенный пример представляет собой дурной стиль программирования. Гораздо яснее выглядит следующий фрагмент, эквивалентный приведенному выше:

if (b != 0) {
    s = 1.0;
}

В более строгом языке Java второй фрагмент корректен, а первый нет.
Язык C++ вводит логический тип bool в явном виде (отметим, что этот тип появился в C++ далеко не сразу!). Переменные типа bool принимают два значения: false и true (истина и ложь). Слова false и true являются ключевыми словами языка C++.
Примеры описания логических переменных в C++:

bool a, b;
bool c = false, d = true;
Оператор sizeof

Переменная одного и того же типа на разных платформах может занимать различное число байтов памяти. Язык Си предоставляет программисту возможность получить размер элемента данного типа или размер переменной в байтах, для этого служит оператор sizeof. Аргумент sizeof указывается в круглых скобках, он может быть типом или переменной. Рассмотрим несколько примеров. Пусть определены следующие переменные:

int i; char c; short s; long l;
double d; float f; bool b;

Тогда приведенные ниже выражения в 32-разрядной архитектуре имеют следующие значения:


размер переменной

размер типа

значение

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:

void f(int x);

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

http://localhost:3232/img/empty.gifКонструирование новых типов

Для создания новых типов в Си можно использовать конструкции массива, указателя и структуры.

Массивы

Описание массива в Си состоит из имени базового типа, названия массива и его размера, который указывается в квадратных скобках. Размер массива обязательно должен быть целочисленной константой или константным выражением. Примеры:

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 - не указатели!).
С указателями возможны следующие два действия:

  1. присвоить указателю адрес некоторой переменной. Для этого используется операция взятия адреса, которая обозначается амперсендом &. Например, строка
a = &c;

указателю a присваивает значение адреса переменной c;

  1. получить объект, адрес которого содержится в указателе; для этого используется операция звездочка '*', которая записывается перед указателем. (Заметим, что звездочкой обозначается также операция умножения.) Например, строка
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 - это
    • массив из 100 элементов типа
    • указатель на
    • функцию с одним аргументом x типа int, возвращающую значение типа
    • void.

Ниже расставлены номера операций в порядке их применения в описании переменной 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 и терминирующий нулевой байт.

Модификатор const

Константы в Си можно задавать двумя способами:

  • с помощью директивы #define препроцессора. Например, строка
    
#define MILLENIUM 1000

задает символическое имя MILLENIUM для константы 1000. Препроцессор всюду в тексте заменяет это имя на константу 1000, используя текстовую подстановку. Это не очень хороший способ, поскольку при таком задании отсутствует контроль типов;

  • с помощью модификатора const. При описании любой переменной можно добавить модификатор типа const. Например, вместо #define можно использовать следующее описание:
    
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 в переводе означает "изменчивый, непостоянный". В Си к описанию переменной следует добавлять слово volatile, если ее значение может изменяться не в результате выполнения программы, а из-за каких-либо внешних событий. Например, переменная может измениться при выполнении программы-обработчика аппаратного прерывания (см. раздел 2.5). Другой причиной "внезапного" изменения значения переменной может быть переключение между нитями при параллельном программировании (см. 2.6.2) и модификация переменной в параллельной нити.
Необходимо обязательно сообщать компилятору о таких изменчивых переменных. Дело в том, что процессор выполняет все действия с регистрами, а не с элементами памяти. Оптимизирующий компилятор держит значения большинства переменных в регистрах, сводя к минимуму обращения к памяти. Непостоянная переменная может изменить свое значение в памяти, но программа будет по-прежнему использовать значение в регистре, которое осталось прежним. Из-за этого выполнение программы нарушится. Модификатор volatile запрещает даже временно помещать переменную в регистр процессора.
Пример описания переменной:

volatile int inputPort;

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

Оператор typedef

В языке Си можно задать имя типа, если его описание достаточно громоздко и его не хочется повторять много раз. В дальнейшем можно использовать имя типа при описании переменных. Для определения типа применяется оператор 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-разрядной архитектуре.

http://localhost:3232/img/empty.gifВыражения

Выражения в Си составляются из переменных или констант, к которым применяются различные операции. Для указания порядка операций можно использовать круглые скобки.
Отметим, что, помимо обычных операций, таких, как сложение или умножение, в Си существует ряд операций, несколько непривычных для начинающих. Например, запятая и знак равенства (оператор присваивания) являются операциями в Си; помимо операции сложения +, есть еще операция увеличить на += и операция увеличения на единицу ++. Зачастую они позволяют писать эстетически красивые, но не очень понятные для начинающих программы.
Впрочем, эти непривычные операции можно не использовать, заменяя их традиционными.

Оператор присваивания

Оператор присваивания является основой любого алгоритмического языка (см. лекцию 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;

http://localhost:3232/img/empty.gifЛогические операции

Логические операции и выражения были подробно рассмотрены в разделе 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;  // Корректно: любой указатель можно
        // неявно привести к обобщенному

http://localhost:3232/img/empty.gif

 
На главную | Содержание | < Назад....Вперёд >
С вопросами и предложениями можно обращаться по nicivas@bk.ru. 2013 г.Яндекс.Метрика