1.4. Обработка данных. Операции. Выражения
Следующий слой выразительных средств языка – средства обработки данных. Это – набор элементарных действий (операций) и их сочетаний (выражений), который позволяет изменять значения переменных. К ним относятся:
традиционные операции над переменными (например, арифметические, логические, поразрядные);
присваивание;
ввод-вывод;
вызов процедур и функций.
Перечисленные средства объединены по назначению в программе. Синтаксически они могут относиться к различным уровням. Например, в большинстве языков программирования присваивание является оператором, т.е. элементом более высокого синтаксического уровня, чем выражения. Аналогичным образом иногда обстоят дела с вызовом функций и вводом-выводом. В Си в этом смысле повезло. Всё перечисленное является операциями и объединено в один уровень – выражений. (Вызов функций в выражениях, а также библиотека функций ввода-вывода будут рассмотрены в 1.6).
Операции, выражения
Для начала – немного терминологии:
операция – элементарное действие по обработке данных (по работе с переменными);
операнд - переменная, константа, выражение, участвующие в операции;
унарная операция - операция с одним операндом;
бинарная операция - операция с двумя операндами;
выражение – элемент синтаксиса с описанием последовательности выполнения операций и их операндов, в котором результат одной операции является операндом другой.
Если посмотреть полный перечень операций в Си, то первое, что бросается в глаза, это их многочисленность. Второе (уже упомянутое), что к операциям относятся такие действия, которые в других языках программирования считаются операторами (например, присваивание). И третье, что все они имеют очень «компактный» синтаксис, т.е. при пропуске или добавлении лишнего символа одна операция превращается в другую. Все это требует внимательного и осознанного отношения к операциям в Си. Перечислим их особенности:
операции разбиты на 16 групп по приоритетам их выполнения;
внутри каждой группы задается направление выполнения операции для последовательности операций одного приоритета. Для большинства из них имеет место естественное направление слева направо. Однако для операций присваивания, условной операции и унарных операций, у которых знак (символ) операции находится перед операндом (слева), направление операций противоположное - справа налево;
большинство операций совместимо по результатам. Это значит, что результат любой операции может быть операндом любой другой операции, то есть их можно комбинировать между собой как угодно, даже в самых «диких» сочетаниях;
в Си отсутствует понятие логического (булевого) типа «истина-ложь». Для его представления используются значения целой переменной: 0 –«ложь», 1 (или любое отличное от нуля значение) – «истина»;
в некоторых операциях возможно изменение значений участвующих в ней операндов;
Действие и результат
«Борьбу с пьянством прекратить,
ибо это – не борьба, и это - не результат».
Из миниатюр М.Жванецкого
Обычно результат операции представляет собой новое, самостоятельное значение, которое затем может использоваться в качестве операнда в последующих операциях. Входные операнды при этом остаются без изменения. В Си схема может быть сложнее - операнды некоторых операций могут изменяться в процессе ее выполнения. Самый простой пример - операция присваивания:
a = b; // Действие над операндом: переменная a получает значение переменной b
// Результат: значение переменной a после присваивания
Наличие в операции результата позволяет использовать ее в контексте (окружении) других операций, например:
c = (a = b) + 5; // Эквивалентно a = b; c = a + 5;
Более интересный случай представляют собой операции инкремента и декремента, в которых действие не совпадает с результатом, например:
a++; // Действие над операндом: переменная a увеличивается нa 1
// Результат: значение переменной до ее увеличения
c = A[i++]; // Эквивалентно c = A[i]; i = i + 1;
Преобразование типов операндов
В выражениях в качестве операндов могут присутствовать переменные и константы разных типов (здесь и далее мы ограничимся пока только известными нам базовыми типами данных). Результат каждой операции также имеет свой определенный тип, который зависит от типов операндов. Если в бинарных операциях типы данных обоих операндов совпадают, то результат будет иметь тот же самый тип. Если нет, то транслятор должен включить в код программы неявные операции, которые преобразуют тип операндов, то есть выполнить приведение типов. Но прежде, чем изучать подробности таких преобразований, ответим на вопрос: «Где они могут происходить»?:
при выполнении операции присваивания, когда значение переменной или выражения из правой части запоминается в переменной в левой части;
при прямом указании на необходимость изменения типа данных переменной или выражения, для чего используется операция явного преобразования типа;
при выполнении бинарных операций над операндами различных типов, когда более «длинный» операнд превалирует над более «коротким», вещественное - над целым, а беззнаковое над знаковым.
Преобразование типов может неявно включать в себя следующие действия:
преобразование целой переменной в переменную вещественную (с плавающей точкой) и наоборот;
увеличение или уменьшение разрядности машинного слова, то есть «усечение» или «растягивание» целой переменной;
преобразование знаковой формы представления целого в беззнаковую и наоборот.
Возможные ошибки, возникающие при таких преобразованиях, никоим образом не фиксируются ни аппаратными средствами, ни исполнительной системой языка, т.е. остаются незамеченными. Поэтому здесь нужно быть особенно внимательным и при необходимости учитывать особенности представления данных во внутренней форме (см. 1.3).
Уменьшение разрядности машинного слова всегда происходит путем отсечения старших разрядов числа. Заметим, что это может привести к ошибкам потери значащих цифр и разрядов:
int n = 1045; // Во внутреннем представлении n=0x00000415 (1024+16+5)
char c; c = n; // Потеря значащих цифр (0x15)
Увеличение разрядности приводит к появлению дополнительных старших разрядов числа. При этом способ их заполнения зависит от формы представления целого:
для беззнаковых целых заполнение производится нулями;
для целых со знаком они заполняются значением знакового (старшего) разряда.
Таким образом, при увеличении размерности целого его значение сохраняется (с учетом формы представления):
int n; unsigned u;
char c = 0x84; n = c; // Значение n=0xFFFFFF84
unsigned char uc = 0x84; u = uc; // Значение u=0x00000084
При преобразовании вещественного к целому происходит потеря дробной части, при этом возможны случаи возникновения ошибок переполнения и потери значащих цифр, когда полученное целое имеет слишком большое значение:
double d1 = 855.666, d2 = 0.5E16;
int n; n = d1; // Отбрасывание дробной части
n = d2; // Потеря значимости
Преобразование знаковой формы к беззнаковой не сопровождается изменением значения целого числа и вообще не приводит к выполнению каких-либо действий в программе. В таких случаях транслятор «запоминает», что форма представления целого изменилась:
int n =- 1; unsigned d;
d = n; // Значение d=0xFFFFFFFF (-1)
При выполнении бинарных операций принят следующий порядок:
короткие типы char, short и битовые поля удлиняются до int, float до double;
если один из операндов – длинный вещественный (long double), то второй также приводится к этому типу;
если один из операндов – длинное целое (long), то второй также приводится к этому типу;
если один из операндов – беззнаковый (unsigned), то второй также приводится к этому типу;
в конце концов остаются целые операнды, сохраняющие свой тип.
Таким образом, короткие типы данных (знаковые и беззнаковые) удлиняются до int и double, а выполнение любой бинарной операции с одним long double, double, long, unsigned ведет к преобразованию другого операнда к тому же типу. Это может сопровождаться перечисленными выше действиями: увеличение разрядности операнда путем его «удлинения», преобразование в форму с плавающей точкой и изменение беззнаковой формы представления на знаковую и наоборот.
Следует обратить внимание на одну тонкость: если в процессе преобразования требуется увеличение разрядности переменной, то на способ ее «удлинения» влияет только наличие или отсутствие знака у самой переменной. Второй операнд, к типу которого осуществляется приведение, на этот процесс не влияет (принятые в примерах размерности short – 16 разрядов, int – 32 разряда):
int l = 0x0031;
unsigned short d = 0xFF000;
l + d ... // 0x00000031 + 0xFF00 = 0x00000031 + 0x0000FF00 = 0x0000FF31
В данном случае производится преобразование укороченного целого без знака (unsigned) к целому со знаком (int). В процессе преобразования «удлинение» переменной d производится как беззнаковое (разряды заполняются нулями), хотя второй операнд и имеет знак. Рассмотрим еще несколько примеров.
int i; i = 0xFFFFFFFF;
Целая переменная со знаком получает значение FFFFFFFF, что соответствует -1 для знаковой формы в дополнительном коде. Изменение формы представления с беззнаковой на знаковую не сопровождается никакими действиями.
short i = 0xFFFF;
int l; l = i;
Преобразование short в int сопровождается «удлинением» переменной, что с учетом представления i со знаком дает FFFFFFFF, то есть целое со значением -1.
unsigned short n = 0xFF00;
int l; l = n;
Переменная n «удлиняется» как целое без знака, то есть переменная l получит значение 0000FF00.
int i; unsigned u;
i = u = 0xFFFFFFFF;
if (i > 5) ... // «Ложь»
if (u > 5) ... // «Истина»
Значения переменных без знака и со знаком равны FFFFFFFF или -1. Но результаты сравнения противоположны, так как во втором случае сравнение проводится для беззнаковых целых по их абсолютной величине, а в первом случае - путем проверки знака результата вычитания, то есть с учетом знаковой формы представления чисел.
И последнее, очень важное свойство: преобразование выполняется для каждой пары операндов независимо, т.е. абсолютно не важно, какие типы операндов имеют место в последующих операциях. «Забывчивость» в этом вопросе приводит к ошибкам:
int a = 5, b = 2; double dd; dd = a / b; // результат dd=2.0, деление int/int
Здесь производится целочисленное деление, несмотря на запоминание результата в вещественной форме.
Классификация операций
Классификацию операций будем проводить не по приоритетам (как это делается в справочниках), а по их назначению. Именно такими группами они будут встречаться нам в различных областях программирования:
арифметические ( +,-,*,/,% ) (см. 4.2);
логические ( &&, ||, ! );
сравнения ( <,>,>=,<=,==,!=);
поразрядные (машинно-ориентированные) ( &,|,^,~,<<,>> ) (см. 9.1);
присваиваниe (=,++,--,+=,-=,*-,/= и т.п.);
работa с указателями и памятью (*,&,sizeof) (см. 5.2, 9.2);
выделение составляющего типа данных ( (),*,[], . , -> ) (см. 5.4) ;
явноe преобразованиe типа ( (тип) );
последовательность ( ","-запятая), условная ( ?: ).
Арифметические операции
Арифметические операции имеют в Си меньше всего специфики. Единственное, на что следует обращать внимание при их выполнении, - это размерность используемых целых переменных и переменных с плавающей точкой, неявные преобразования типов данных в выражениях и связанные со всем этим возможные потери значащих цифр (значимости) результата.
Операция % вычисляет остаток от деления первого операнда на второй. Она имеет также другой, содержательный смысл: второй операнд-константа выступает ограничителем возможных изменений первого операнда и называется модулем. Название такой операции звучит как "... по модулю ...":
a = (a + 1) % 16; // a присвоить a+1 по модулю 16
Операции сравнения и логические операции
В Си отсутствует особый базовый тип данных для представления логических значений «истина» и «ложь». Для этой цели используются значения целой переменной. Значение 0 всегда является "ложью". Значение 1 -"истиной". Такие значения дают операции сравнения и логические операции. Вообще, в широком смысле любое ненулевое значение является истинным. В такой интерпретации проверяются условия в операторах программы. Поэтому можно записать:
if (1) { A } else { B } // Всегда выполнять B
while (1) { ... } // «Вечный» цикл
if (k) { A } else { B } // Эквивалентно if(k !=0)
Все операции сравнения дают в качестве результата значения 1 или 0. Следовательно, их можно использовать совместно с арифметическими и другими операциями:
a = b > c; // Запомнить результат сравнения
a = (b > c)\* 2; // Принимает значения 0 или 2
Логические операции И (&&) , ИЛИ (||) и НЕ (!) едины для всех языков программирования и соответствуют логическим функциям И, ИЛИ и НЕ для логических (булевых) переменных. Операция И имеет результатом значение «истина» тогда и только тогда, когда оба ее операнда истинны, то есть по отношению к операндам - утверждениям звучит как «одновременно оба». Операция ИЛИ имеет результатом значение «истина», когда хотя бы один из операндов истинен, то есть характеризуется фразой «хотя бы один»:
if (a < b && b < c) // если ОДНОВРЕМЕННО ОБА a < b и b < c, то...
if (a == 0 || b > 0) // если ХОТЯ БЫ ОДИН a==0 или b > 0, то...
Логические операции И и ИЛИ имеют в Си еще одно свойство. Если в операции И первый операнд имеет значение «ложь», а в операции ИЛИ – «истина», то вычисление выражения прекращается, потому что значение его уже становится известным («ложь» -для И, «истина» -для ИЛИ). Поэтому возможны выражения, где в первом операнде операции И проверяется корректность некоторой переменной, а во втором - она же используется с учетом этой корректности:
if (a >=0 && sin(sqrt(a)) >0) ...
В данном примере второй операнд, включающий в себя функцию вычисления квадратного корня, не вычисляется, если первый операнд – «ложь».
Особо следует отметить операцию логической инверсии (отрицания) -"!". Значение «истина» она превращает в «ложь» и наоборот. Если считать значением «истина» любое ненулевое значение целой переменной, то эту операцию для целых следует понимать как проверку на 0:
while(!k) {...} // эквивалентно while(k==0) {...}
Операции присваивания
К операциям присваивания относятся все операции, которые меняют значение одного из операндов. В Си их целых три группы:
обычное присваивание (=);
присваивание, соединенное с одной их бинарных операций (+=, -=, *=, /=, %=, <<=, >>=, &=, |=, ^=);
операции инкремента и декремента (увеличения и уменьшения на 1).
Операция присваивания "=" сохраняет значение выражения, стоящего в левой части, в переменной, а точнее, в адресном выражении, стоящем а правой части. Термин адресное выражение (или l-value) используется для обозначения тех выражений, которым соответствуют исходные объекты (переменные) в памяти программы. На данном уровне знакомства со структурами данных – это простые переменные и элементы массивов.
Различная интерпретация левой и правой части соответствует дуализму понятия «имя переменной»: в левой части под ним понимается ссылка (адрес этой переменной в памяти), в правой части – ее значение.
Разница между ссылкой и значением такая же, как между стаканом и его содержимым.
При выполнении операции присваивания тип выражения в правой части преобразуется к типу адресного выражения в левой. Результатом операции является значение левой части после присваивания, соответственно, тип результата - это тип левой части. Кроме того, присваивание - одна из немногих операций с направлением выполнения «справа налево». Из сказанного следует возможность многократного присваивания «справа налево», в котором результат каждого из них используется как правая часть последующего:
long a; char b; int c;
a = b = c; // эквивалентно b = c; a = b;
В данном случае при первом (правом) присваивании тип int преобразуется к char, а результатом операции является значение переменной b типа char после выполнения этого присваивания. Затем аналогичным образом происходит присваивание b в a.
Операция присваивания, соединенная с одной из бинарных операций, - это частный случай, когда результат бинарной операции сохраняется (присваивается) в первом операнде:
a +=b; // эквивалентно a = a + b;
Приведенный выше эквивалент этой операции верен лишь в первом приближении, потому что в этих операциях левый операнд, если он является адресным выражением, вычисляется один, а не два раза. Следующий пример показывает это:
A[i++] +=b; // эквивалентно A[i] = A[i] + b; i++;
Операции инкремента и декремента увеличивают или уменьшают значение единственного операнда до или после использования его значения в выражении:
int a; // Эквивалент Интерпретация
a++; // Rez=a; a=a+1; Увеличить на 1 после использования
++a; // a=a+1; Rez=a; Увеличить на 1 до использования
a--; // Rez=a; a=a-1; Уменьшить на 1 после использования
--a; // a=a-1; Rez=a; Уменьшить на 1 до использования
Явное преобразование типа
В тех случаях, когда программиста не устраивает принятый порядок неявного преобразования типов, он может сам преобразовать результат к такому типу, какой ему необходим. Это можно сделать, в частности, путем присваивания результата дополнительной переменной, во время которого требуемое преобразование будет произведено. Но он может сделать то же самое внутри выражения «на лету» с помощью специальной операции. Она представляет собой имя типа, к которому осуществляется приведение, заключенное в круглые скобки и стоящее перед операндом. В качестве примера рассмотрим получение дробной части числа:
double x,d; // double x,d; int n;
d = x - (int)x; // n = x; d = x - d;
Операции выделения составляющего типа данных
Особенность этих операций, которая служит основанием для их объединения, состоит в том, что операций не изменяют данные, а осуществляют переход от одной формы представления к другой - от производного типа к его составляющей и наоборот (см. 5.5). Эти операции касаются, прежде всего, работы с различными типами данных и их конструирования в программе.
Условная операция и операция «запятая»
Эти операции являются частью алгоритмического уровня языка, они позволяют «встроить» внутрь выражения небольшую условную конструкцию или последовательность независимых действий.
int a; double b;
c = x + a > b ? a : b; // Условие ? Выражение для «истина» : Выражение для «ложь»
Операция использует три операнда и два знака операции (?:). Первым операндом является условие. Если оно истинно, то результатом становится значение второго операнда, если ложно - то третьего. В данном примере вычисляется максимальное значение переменных a,b. Тип результата операции определяется по правилам неявного преобразования типов для второго и третьего операндов. Он будет всегда один и тот же, независимо от выполнения условия. В данном случае - всегда double, так как переменная a будет приведена к этому типу.
Операция "," позволяет соединить в одно выражение несколько выражений, не связанных между собой результатами, то есть просто перечислить их. Необходимость ее возникает, когда программисту нужно «втиснуть» в то место программы, где по синтаксису стоит одно выражение, несколько независимых друг от друга. Типичный пример - работа в цикле с использованием двух индексов массива, перемещающихся от концов к середине:
for (i=0; i<n; i++) {…A[i]…} // Обычный цикл
for (i=0 , j=n-1; i<j; i++ , j--) // Цикл с двумя индексами
{...A[i]...A[j]...}
Результатом операции является значение последнего выражения. Поэтому если в группе выражений, соединенных запятыми, есть условие, которое надлежит проверить, то оно должно быть последним. При этом тип результата также равен типу последнего выражения. Например, в заголовке цикла в начале каждого шага проверяется условие, перед которым выполняется присваивание:
while( a=b, a < 0) {....}
Подводные камни и маленькие хитрости
Компактный синтаксис операций, а также их совместимость по результатам служат источником значительного количества ошибок такого плана: при пропуске или, наоборот, дублировании знака операции может получиться другая операция, которая при принятой в Си «свободе нравов» будет синтаксически корректна, выполнима, но даст совершенно незапланированный результат. «Сообразительный» транслятор может сопроводить такую ошибку предупреждением (warning). Приведем примеры таких ошибок:
if (a=b) // вместо if (a==b)
while (a << 3) // вместо while (a < 3)
if (a && 0x10) // вместо if (a & 0x10)
Трудно обнаруживаемые ошибки возникают при неявных преобразованиях типов в операциях, особенно при сочетании знаковой и беззнаковой форм представления:
char c[80];
#define CODE 193
if (c[i] == CODE) // Эквивалентно (int)c[i] == 193
В данном примере идентификатором CODE обозначена целая константа, которая имеет смысл кода символа, на наличие которого затем проверяются элементы массива символов. Но дело в том, что такая операция будет давать значение «ложь» всегда. Тип char представляет символы как знаковые байты (целые минимальной длины), поэтому этому коду в данной форме представления соответствует отрицательное значение -63. Так как любая операция преобразует операнды char к int, то получится интересное сочетание "-63 == 193", имеющее значение «ложь» вместо планируемого «истина». В таких случаях, когда разрядности переменных меняются, лучше не смешивать знаковую и беззнаковую формы. В данном случае исправить ошибку можно несколькими способами
#define CODE -63 // Непонятно
#define CODE (char)193 // Приемлемо
#define CODE '\301'
unsigned char c[80]; // Лучше всего для символов с кодами >128
При выполнении операций с переменными различной разрядности нужно помнить, что последовательность преобразования разрядностей (типов) связана с последовательностью и приоритетами выполнения операций. Поэтому само по себе наличие в выражении операнда большей разрядности еще не гарантирует правильности вычислений для больших значений. Это видно на примере, где используется переменная типа long для хранения произведения переменных типа int. Ошибка состоит в том, что операция умножения все равно будет производиться с целыми размерности int, что может привести к потере значащих цифр произведения:
Int a,b; long c;
c = a \* b; // Неправильно
c = (long)a \* b; // Правильно
Операция присваивания, операция «запятая» и условная операция позволяют выполнять многие действия «на лету», не выходя за пределы синтaксиса выражения в условиях, проверяемых в оперaторах if, while, например:
while ((c = getchar()) !='\*') {...c...}
Здесь в переменной c запоминается результат функции, вызванной во время проверки условия в операторе while, с целью дальнейшего его использования в теле оператора.
while (x0 = x1, x0 > 0) {... x1 =f(x0) ...}
Присваивание выполняется во время проверки условия в операторе цикла.
for (...; d > 0 ? a > b : b >= a; ...) {...}
В зависимости от значения переменной d меняется условие продолжения цикла for.
При наличии в программе нескольких вариантов выбора по группе условий программа становится «сильно ветвистой», например:
if (a < b)
if (a < c)
if (b < c) {...} // a < b && a < c && b < c
else {...} // a < b && a < c && b >=c
else if (b < c) {...} // a < b && a >=c && b < c
else {...} // a < b && a >=c && b >=c
else ...
Можно воспользоваться тем, что операция сравнения дает целый результат (1 или 0) и сформировать переменную, принимающую уникальное значение для каждой комбинации сравнений. Тогда программа примет хотя и менее понятный, но зато более регулярный вид:
int n;
n = (a < b)*4 + (a < c)*2 + (b < c);
switch(n) {
case 0:... break; // a >=b && a >=c && b >=c
case 7:... break; // a < b && a < c && b < c
}
Роль символа «точка с запятой»
Символ ";" (точка с запятой), поставленный в конце выражения, превращает его в конструкцию более высокого уровня - оператор. Для обозначения его роли лучше всего подходит слово «ограничитель» - он ограничивает текущую синтаксическую конструкцию. То же самое он делает в других местах программы, например, в определениях переменных. Поэтому транслятор, обнаружив начало выражения или определения, продолжает его обработку, пока не встретит ";". Если программист забыл ограничить конструкцию этим символом, то транслятор «не заметит» окончания выражения и по инерции будет продолжать анализ последующей части программы как часть последнего. Это может привести к появлению ошибок трансляции, которых на самом деле нет в программе.
a = b + c - 5 // Здесь пропущен символ ";"
if (a < b) {} // Здесь транслятор обнаружит ошибку
// в выражении, которое с его точки
// зрения еще не закончилось
else {} // В этой части программы транслятор может
// обнаружить «наведенную» ошибку
// Эту часть программы транслятор пропустит
Конечно, здесь много зависит от особенностей транслятора, но чтобы не проверять его на «сообразительность», лучше приучить себя вовремя ставить этот ограничитель.
Примечание: в Паскале символ «;» называется разделителем - он разделяет два оператора в простой последовательности. Эта тонкость в терминологии приводит к тому, что программы на Паскале и Си с точки зрения расстановки этого символа существенно различаются.
Вопросы без ответов
Определить значения переменных после выполнения действий.
//------------------------------------------------- 1
int i1 = 0xFFFFFFFF; i1 ++;
//------------------------------------------------- 2
unsigned u1,u2,u; u1 = 5; u2 = -1; u=0;
if (u1 > u2) u++;
//------------------------------------------------- 3
int i1 = 0x01FF; char c; c = i1; i1 = c;
//------------------------------------------------- 4
int i1 = 0x01FF;
unsigned char c; c = i1; i1 = c;
//------------------------------------------------- 5
double d1,d2,d3;
d1 = 2.56;
d2 = (int)d1 + 1.5;
d3 = (int)(d1 + 1.5);
//------------------------------------------------- 6
double d1 = 2.56;
int i;
i = (d1 - (int)d1) \* 10;
//------------------------------------------------- 7
i=0; if (i++) i++;
//------------------------------------------------- 8
i=0; if (++i) i++;
//------------------------------------------------- 9
m = a > b ? a : b;
//------------------------------------------------ 10
m = (a \* b) > 0;
//------------------------------------------------ 11
m = a > 0 ? a : -a;