Основы разработки программного обеспечения на примере языка С

Язык программирования Си

Разбить на страницы
Показывать лекцию целиком

Язык Си подобен обоюдоострому лезвию, при помощи которого легко создать как изысканное блюдо, так и кровавое месиво.

Язык Си был создан в 1972 г. Деннисом Ритчи в ходе разработки операционной системы (ОС) Unix. Практически вся ОС была написана на Си. Большие выразительные возможности и легкость расширения способствовали быстрому распространению этого языка. Компиляторы для языка Си доступны практически для всех существующих на данный момент ОС и платформ.

В отличие от большинства языков высокого уровня (Ада, Алгол и др.), которые начинали применять только после принятия соответствующих стандартов на них, язык Си был создан как рабочий инструмент эффективного кодирования и не претендовал на общественное использование. Спецификация языка была определена в книге Кернигана Б. и Ритчи Д. (перевод на русский язык [8]). Эта книга оставалась неформальным стандартом на язык Си вплоть до 1989 г., когда был принят стандарт ANSI. На данный момент существует еще ISO-стандарт на этот язык.

В качестве отличительных особенностей языка Си можно выделить:

  • препроцессорные директивы;
  • необходимость определять переменные, но возможность это делать в любом месте (до первого использования переменной);
  • использование фигурных скобок {} для структуризации текста программы;
  • обязательное наличие функции main()
  • 3.1. Типы в языке Си

    Как и в любом языке программирования, в Си определены базовые типы и набор механизмов для задания произвольных пользовательских типов на основе базовых.

    Базовые типы

    В качестве базовых в языке Си определены следующие типы.

    Целочисленные:

  • char - целый длиной не менее 8 бит (интерпретируется обычно как литера);
  • short /short int/ - короткий целый;
  • int - целый;
  • long - длинный целый.
  • Для всех целочисленных типов можно определить, будут они знаковые или нет. Это делается прибавлением слова unsigned или signed перед названием типа (по умолчанию - signed).

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

    Более удачным оказалось представление отрицательных чисел в виде дополнения, поскольку одна и та же схема могла выполнять и сложение, и вычитание. Некоторые разработчики выбирали дополнение до единицы, когда $$-n$$ получалось из $$n$$ путем простого инвертирования всех бит (так называемый обратный код). Другие выбирали дополнение до двойки, когда $$-n$$ получалось путем инвертирования всех бит и прибавлением единицы. Недостатком первого способа было наличие двух форм представления нуля (0...0 и 1...1). Это неприятно, особенно если доступные инструкции сравнения являются неадекватными. Различия этих форм показаны в табл. 3.1.

    Представление в прямом, обратном и дополнительных кодах
    Десятичное представление Прямой код Дополнительный код Обратный код
    Знак Модуль
    2 0 010 010 010
    1 0 001 001 001
    0 0 000 000 000 или 111
    -1 1 001 111 110
    -2 1 010 110 101

    Сегодня во всех компьютерах используется арифметика с дополнением до двойки, обычно называемая просто дополнительным кодом. Как мы видим, у отрицательных чисел старший разряд равен 1. Добавление спецификации unsigned означает, что весь код следует воспринимать как модуль числа. При этом нижняя строчка таблицы, соответствующая -2 в десятичной системе и представляющая в дополнительном коде 110, будет обрабатываться как десятичное число шесть. Естественно, при signed-спецификации этот же код будет обрабатываться как - 2.

    Числа с дробной частью можно представлять в формах с фиксированной и плавающей точкой. В современной аппаратуре для вещественных чисел обычно поддерживается арифметика с плавающей точкой, т.е. число $$x$$ представляется двумя целыми числами - порядком $$e$$ и мантиссой $$m$$, так что $$x=2^{e}m$$. Это позволяет значительно расширить диапазон представляемых в машине чисел.

    Вещественные:

  • float - вещественный одинарной точности;
  • double - вещественный двойной точности;
  • long double - вещественный максимальной точности.
  • Недостатком такого представления является то, что та же память теперь делится между двумя целыми числами: мантиссой числа и его порядком. Это приводит к сокращению числа разрядов мантиссы, а следовательно, к меньшей ее точности. Более того, практически одно и то же число может теперь быть представлено разной комбинацией разрядов: чуть большим или чуть меньшим значением. Т.е. сравнение чисел надо производить с учетом некоторого диапазона чувствительности. Стоит при этом помнить еще и о том, что складывая очень большие и очень маленькие вещественные числа из различных диапазонов, каждое из которых помещается в машинное представление, мы рискуем потерять младшие разряды мантиссы, а значит, и значение малой добавки.

    Разработчики аппаратуры шли на различные ухищрения для смягчения этого свойства: отказывались от хранения старшего разряда мантиссы для увеличения ее точности, переходили к удвоенной разрядной сетке, увеличивали основание порядка до 8 и даже до 16. Однако проблемы округления оставались. Известны случаи, когда в подобных машинах можно было найти такие значения x и у, что для некоторого малого положительного ? выполнялось соотношение $$(х + \varepsilon ) x(y + \varepsilon )<(x \times y)$$, т.е. умножение утрачивало свойство монотонности.

    Соглашения об именах

    Мы уже говорили, обсуждая программную документацию, что для текстов программ характерно однократное написание и многократное прочтение. Весьма значимым при чтении текста программы является отделение имен одного класса объектов от других. Проще всего для этого ввести некоторые соглашения об именах, например имена типов и констант писать ТОЛЬКО заглавными буквами, начинать определение имени без знаковых типов и буквы U (unsigned), перечислимых типов - с буквы E (enumeric) и тому подобное. Для этих целей можно воспользоваться следующими определениями:

    typedef char   CHAR; /* general text. Range 0..127 */
    typedef unsigned char UCHAR; /* extended text. Range 0..255 */
    typedef unsigned short BOOL; /* Time efficient Boolean */
    typedef unsigned har TBOOL; /* Tiny (space efficient) Boolean */
    typedef char   TINY; /* 8 bit. Range (-2^7-1)..(+2^7) */
    typedef unsigned char UTINY; /* 8 bit. Range (0)..(+2^8) */
    typedef unsigned char TINYBITS; /* 8 bit. For bit manipulation use */
    typedef short SHORT; /* 16 bit. Range (-2^15-1)..(+2^15) */
    typedef unsigned short USHORT; /* 16 bit. Range (0)..(+2^16) */
    typedef unsigned short SHORTBITS; /* 16 bit. For bit manipulation use */
    typedef long LONG; /* 32 bit.Range (-2^31-1)..(+2^31)*/
    typedef unsigned long ULONG; /* 32 bit.Range (0)..(+2^32) */
    typedef unsigned long LONGBITS; /* 32 bit.For bit manipulation use */
    typedef int INT; /* Natural machine size integer */  
          

    Как мы видим, один и тот же тип языка Си, например unsigned char, может быть многократно переопределен под различными именами в зависимости от целей его использования.

    Типы, определяемые пользователем

    Программисту позволяется вводить в программу свои собственные средства кодировки, приписывая каждому значению собственное имя (идентификатор). Для этого в языке Си введен перечислимый тип.

    Перечислимые:

    enum - перечисление констант, например:

    enum { Yellow=5, Cyan, Magenta};  
          

    или, если следовать правилу использования в именах констант только заглавных букв:

    enum { YELLOW=5, CYAN, MAGENTA};  
          

    Здесь после первого элемента стоит знак "=" и за ним число, это число определяет, с какого значения идет нумерация перечисленных констант (следующая константа будет иметь значение на единицу больше). Если не указывать это начальное число, то нумерация констант будет идти от нуля. Таким образом, к перечисленным константам можно обращаться по их номерам, а можно задать и полностью свою систему обозначений:

    typedef enum 
    {
      START END FLAG    = 0x0001,  /*  Event  Flag  0  * /
      TEST END FLAG    = 0x0002,  /*  Event  Flag  1  * /
      SYS INIT END    = 0x0004,  /*  Event  Flag  2  */
      CODEC CODE DOWNLOADED =  0x0008,  /*  Event  Flag  3  */
      MODEM CODE DOWNLOADED =  0x0010  /*  Event  Flag  4  */
    } E_RUN_FLAG;
      
          

    Такое определение перечислимого типа позволяет закодировать значения единицы в разрядах с нулевого по четвертый соответственно. Важно только помнить, что константы типа перечисляются ВСЕГДА в порядке возрастания их значений. И при соблюдении ранее указанных правил наименований если вы где-либо в программе встретите строку определения:

    E_RUN_FLAG My_Indicator;  
          

    то вы легко догадаетесь, что переменная My_Indicator имеет перечислимый тип.

    Ключевое слово void определяет отсутствие типа и используется для "нейтрализации" значения, возвращаемого функцией [9].

    Сложные типы

    В языке Си определены следующие механизмы для задания сложных типов:

  • массивы;
  • структуры;
  • указатели;
  • строки;
  • объединения.
  • Массивы

    Массивы представляют собой однородную последовательность элементов, каждый из которых характеризуется порядковым номером в данной последовательности. Номер элемента указывается вслед за идентификатором последовательности (идентификатором массива) в квадратных скобках: My_Array[i] , Your_Array[5] и т.п. Самый первый элемент массива имеет порядковый номер 0 (нуль).

    Объявление массива в программе в простейшем случае может выглядеть так:

    int My_Vector[3] ;  
          

    где константа 3 указывает, что последовательность, представляемая массивом, содержит три элемента: My_Vector [0], My_Vector [1], My Vector [2].

    При этом следует помнить, что упоминание в программе самого идентификатора массива практически эквивалентно указанию адреса его нулевого элемента.

    Записи

    Записи (или структуры) представляют собой набор полей, объединенных вместе. Можно работать и с каждым полем отдельно, и со всеми полями, как с записью, вместе.

    Вот пример задания структуры:

    struct goods 
      {
        char * name; 
        long price; 
        int volume;
      };
          

    Здесь goods - имя структурного типа. В этом случае объявление переменной food такого типа будет иметь вид:

    struct goods food;  
          

    Если же объявить структуру как:

    typedef struct 
      {
        char * name; 
        long price; 
        int volume;
      } GOODS;
          

    то можно будет объявлять переменные, используя только имя типа:

    GOODS food;  
          

    Язык Си разрешает присваивание структур (в отличие от присваивания массивов, которое может быть выполнено только поэлементным присваиванием).

    При объявлении переменной структурного типа можно проинициализировать ее значение при помощи следующей конструкции:

    GOODS food = {"milk", 25.6, 1} ;  
          

    Для работы с полями в языке используется составное имя с точкой:

    food.price = 27.5;  
          

    Указатели

    Указатели в языке Си объявляются при помощи символа "*":

    int * pInt;  
          

    Здесь переменная pInt - это указатель на int или, проще, адрес переменной целого типа. Доступ к значению, хранящемуся по адресу указателя, производится через разыменование:

    *pInt=6;  
          

    Забавно, что если написать обе строки (объявление указателя и присваивание) подряд, то получится синтаксически правильный фрагмент, но по смыслу он ошибочен. Объявлением указателя мы выделили память под сам указатель, т.е. под адрес некоторого объекта. Однако память под сам объект не выделена, и поэтому записывать значение 6 некуда.

    При помощи оператора "" можно получить адрес уже существующей переменной. Следующий пример демонстрирует, как можно установить указатель pint на переменную varlnt типа int:

    int * pint; 
    int varint; 
    pint = varInt;  
          

    После этого уже можно выполнять присваивание *plnt=6;. Теперь значение 6 попадет в память переменной varint.

    Указатель, "не указывающий никуда", в языке Си называется Null. Это единственная неарифметическая константа. Иногда она представлена как NULL.

    Сами по себе указатели не являются сложными типами с точки зрения обращения к ним. Их значением является адрес некоторого программного объекта. Как правило, они указывают на некоторый тип, который в свою очередь уже может иметь сложную составную структуру. Специфика работы с указателями заключается в том, что, например, префиксная или постфиксная операция ++ будет изменять значение указателя на 1, 2, 4 или некоторую другую величину в зависимости от длины (количеств байт, отведенных для представления) того типа, на который указывает этот указатель.

    Еще одной и наиболее важной проблемой указателей является несогласованность времени жизни указателя (адреса объекта) и программного объекта, на который он указывает.

    Например, функция сохраняет адрес некоторой своей локальной переменной в глобальном указателе. По выходу из функции памяти объекта уже нет. Она может быть перераспределена каким угодно образом, а адрес, где "все это" лежало до сих пор, существует и может быть использован для обращения.

    Ошибки подобного рода очень трудно обнаружить, поскольку до некоторого времени поведение программы с дефектами такого вида может быть весьма правдоподобным. Это происходит до тех пор, пока "отданная" память никем не переопределялась, а потом такая программа "вдруг" перестает работать.

    Строки

    Для работы со строками в Си не введено специального типа, поэтому для представления элементов строки используется символьный тип. Принято, что строка - это массив символов (байт), заканчивающийся нулевым байтом ('\0'). Соответственно, пустая строка начинается именно с этого символа (байта с нулевым кодом).

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

    Объединения

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

    Пример определения типа объединения:

    union Big Word
    {
      long Bg long; 
      char *Bg chart [4] ;
    }
          

    А переменная X_Var такого типа определяется следующим образом:

    union Dig Word X_Var;  
          

    Данные типа union Big_Word занимают память, необходимую для размещения наибольшего из своих элементов, и могут рассматриваться как аналог вариантной записи в языке Паскаль.

    Константы

    Константы в языке Си могут иметь одинаковые значения при различных формах своего представления. В особенности это относится к числовым значениям.

    10, 012 и 0xA в языке Си будут соответствовать одному и тому же значению 10 или в двоичной форме 1010. Просто числовые константы, начинающиеся с цифры 0, считаются в Си заданными в восьмеричной форме представления, а с префиксом 0х - шестнадцатеричными.

    Вещественные числа также можно записывать в различных формах 12.5, 1.2e1, 125e-1, где символ "е" или "Е" используется для отделения мантиссы числа от порядка. Предполагается, что вещественная константа всегда положительна, а предшествующий ей знак "минус", если указан, рассматривается как унарная операция смены знака.

    Еще следует обратить внимание на символьные константы. Они записываются в одиночных кавычках: 'A', 'a', ' + ', '-' и т.п. В случае необходимости можно определить символ его кодом в таблице '\12' или '\xA', задавая после обратной наклонной черты номер символа в восьмеричной или шестнадцатеричной системе.

    Ранее говорилось, что следует различать символы и строки символов, которые задаются двойными кавычками. Так, константе "A" будет соответствовать строка из двух символов: собственно 'A' и '\0', т.е. к байту символа будет добавлен второй байт с нулевым значением для представления признака конца строки.

    3.2. Унарные операторы

    Кроме бинарных операторов, в языке Си существует достаточно большое число унарных операторов.

    Например, допустима следующая запись:

    х=9; —х++++;  
        

    После ее выполнения х будет равен 10. Два минуса слева означают уменьшение значения х на единицу до использования значения переменной в выражении, два раза по два плюса после х означают два раза увеличить х на единицу после использования значения х в расчете выражения.

    Попробуйте, например, рассчитать, чему будет равен х после выполнения следующих строчек на языке Си:

    int x = 1;
    x += x++ + ++x;
        

    (запись х + = 5 эквивалента записи х = х + 5).

    Любопытно, что некоторые компиляторы не умеют правильно интерпретировать подобную "мешанину" из унарных и бинарных операторов в выражении с одной переменной. Тем более, стоит избегать подобных форм записи, сложных для интерпретации. Это не только дополнительная потеря времени, но и потенциальный источник ошибок.

    3.3. Средства низкого уровня

    Как уже было сказано, адрес любой переменной получается при помощи операции "", указанной перед именем этой переменной. С результатом такой операции можно производить все арифметические действия, доступные для целых чисел.

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

    Как и в большинстве языков программирования, Си допускает переопределение типов, в ходе которого никаких преобразований внутреннего представления не происходит, однако появляется возможность работать с одним типом как с другим. Для этого достаточно перед переменной поставить в скобках название нового типа:

    int iv; char cv; cv=(char)iv;  
        

    Кроме работы с адресами и преобразования типов, в языке Си предусмотрен набор битовых операций:

    - поразрядная конъюнкция;

    ^ - поразрядное исключающее или;

    | - поразрядная дизъюнкция;

    << - поразрядный сдвиг влево;

    >> - поразрядный сдвиг вправо.

    Не стоит их путать с операциями логическими:

    - логическая конъюнкция;

    ! - отрицание;

    || - логическая дизъюнкция.

    Так, 21 дадут результат 0 (ложно), 2|1 дадут результат 3 (истинно), а 21 дадут 1 (истинно) и 2||1 тоже дадут 1 (истинно).

    Поразрядное кодирование

    В ряде случаев для представления кодовых значений удобно давать наименования группам разрядов машинного слова или даже отдельным разрядам. Обычно такие значения трактуются как беззнаковые (unsigned) числа. Подобные описания в Си описываются как структуры с битовыми полями. Например:

    struct STATUS_FLAG
      {
        unsined Overflow :1;
        unsined Start_Up :1;
        unsined Source_Code :4;
        unsined Result_Id :2;
    } In_Out_Status;  
        

    Эта структура описывает 8-битовый байт, разделенный на четыре поля. Первые два из них являются отдельными битами со значениями 0 или 1. Третье поле Source_Code представляет собой нормальное 4-разрядное шестнадцатеричное число, а последнее поле (два разряда) может представлять коды от 0 до 3.

    3.4. Управление вычислениями

    В язык Си включен достаточно развитый набор средств, позволяющий управлять вычислительным процессом и производить те или иные действия в зависимости от обрабатываемых данных.

    Условные выражения и условные операторы

    Например, нам необходимо проверить, что устройство присоединено к системе, для чего проверяется значение логической функции Check_For_Connection ( ). При благоприятных условиях (устройство подключено) мы должны выставить флаг передачи данных Transmit равным единице. Программный код может выглядеть так:

    if (Check For Connection() == TRUE)
      {
        Transmit = 1;
      }
      else
      {
        Transmit = 0;
      }  
          

    То же самое будет, если мы вспомним, что функция Check_For_Connection ( ) сама имеет логическое значение.

    if (Check For Connection())
      {
        Transmit = 1;
      }
      else
      {
        Transmit = 0;
      }
          

    Или, используя условное присваивание:

    Transmit = Check For Connection()?1:0;  
          

    и наконец:

    Transmit = Check For Connection();  
          

    В последнем случае мы "вспомнили", что логическое значение "истинно" представляется в языке Си единицей, а "ложно" - нулем. Мы специально не воспользовались средствами приведения (преобразования) типа, чтобы добиться максимальной лаконичности записи.

    Однако эта последняя запись и самая неудобная в смысле сопровождения программного кода. Он стал наименее читаемым и сложно изменяемым при изменении соглашений о связях. С другой стороны, хорошо оптимизирующий компилятор во всех четырех случаях может построить одинаковый код машинной программы.

    С точки зрения понимания того, что делается в программе, первая запись является наиболее удачной. Она же наиболее пригодна для тестирования, так как ветви вычислений в ней явно разделены. Кроме того, даже возвращаемое функцией Check_For_Connection () значение проверяется в отдельной операции сравнения.

    Операторы цикла

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

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

    Давайте посмотрим на фрагмент программы, определяющий, есть ли в строке, хранящейся в массиве char Str_In [20], стоящие подряд повторяющиеся символы. Напомним, что признаком конца строки в языке Си является символ с кодом '\0'.

    int i, Yes; 
    char Ch;
    i = 0; Ch = Str In[ i] ; Yes = 0; 
    while ( Ch != '\0' )
      { if ( Ch == Str_In[ ++i]  )
        Yes = 1;
        Ch  = Str In[ i] ;
      }  
          

    Такой цикл будет работать до тех пор, пока не "наткнется" на конец строки. И значение переменной Yes, равное единице, покажет нам, встречались ли в строке стоящие подряд одинаковые символы. Но цикл можно прекратить и раньше, если в инвариант включить проверку значения переменной Yes:

    int i, Yes; 
    char Ch;
    i = 0; Ch = Str In[ i] ; Yes = 0; 
    while ( Ch != '\0'  Yes == 0 )
      {
        Yes = ( Ch == Str_In[ ++i] ) ;
        Ch  = Str In[ i] ;
      }
          

    Теперь цикл будет прекращаться, как только в строке встретится пара одинаковых символов. В этом случае и тело удалось упростить, так как если соседние символы равны, то переменная Yes становится истинной и цикл прекращается.

    Используя форму цикла do-while или, как его еще называют, цикл с постусловием, можно еще сократить текст подобного фрагмента программы:

    int i, Yes; 
    char Ch;   
    i = 0; Ch = Str In[ i] ; 
    do
      { Yes = ( Ch == Str_In[ ++i]  )  ;
      Ch = Str In[ i] ;
      }
    while ( Ch != '\0'  Yes == 0 );
          

    Как видим, удалось обойтись без начальной инициализации переменной Yes, поскольку инвариант цикла в подобном операторе вычисляется уже после того, как тело цикла выполнилось один раз и, следовательно, значение переменной Yes уже определилось.

    Еще один вид цикла имеет заголовок, определяющий более сложные правила выполнения тела. Это цикл for. В заголовке этого цикла в круглых скобках записываются три блока операторов. Первый (инициализация цикла) выполняется один раз перед началом работы. Второй - инвариант цикла, определяющий предусловие выполнения тела. Третий оператор выполняется в цикле сразу после тела. Фактически он является завершением тела - его последним оператором.

    Проще всего проиллюстрировать этот вид цикла на примере обнуления первых 20 элементов некоторого массива My_Array:

    int i;
    for (i = 0;i<20; My Array[ i++] = 0 ) ;
          

    В данном примере собственно определено пустое тело. Все его полезные действия (обнуление очередного элемента массива и увеличение индекса - параметра цикла) уложились в третий элемент заголовка. Более сложный и более "классический" пример использования цикла for приведен ниже.

    Пусть мы имеем некоторый 8-битовый код в переменной

    unsigned char Input Byte  
          

    и наша задача - поменять местами биты этого кода. Попробуем применить для этого следующий фрагмент программы:

    unsigned char Reversed Byte; 
    unsigned char i;
    Reversed Byte = 0;
    for (i=0; i < 8; i++)
    {
      Reversed Byte <<= 1;  /*  Shift output byte left.  */
      if (Input Byte  1)  /*  If input byte has 1 in the  first  */
      Reversed_Byte |= 1;  /*  pos. put 1 into the output  byte.  */
      Input Byte >>=1;  /*  Shift input byte right for the  */
    }  /* next bit */  
          

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

    Reversed_Byte |= Input_Byte  1;  
          

    Причем в данном случае краткость не ухудшит понимание программного кода, потому что и левая, и правая части оператора присваивания имеют общую битовую структуру.

    С помощью цикла for можно записать и решение задачи по проверке наличия одинаковых символов в строке:

    int i, Yes; 
    char Ch;
    for (i = 0, Ch = Str_In[ i] ; Ch != '\0'  Yes == 0;)
    {
      Yes = ( Ch == Str_In[ ++i]  )  ;
      Ch = Str In[ i] ;
    }  
          

    За счет эквивалентности значений некоторых типов (символьных, целых и логических) можно найти более короткую запись самого цикла:

    for (i = 0 ; Ch = Str_In[ i]  ! Yes = (Ch == Str_In[ i+1] ); ++i);  
          

    В этой записи используется следующее свойство: условие инварианта цикла вычисляется до тех пор, пока не будет установлена его истинность или ложность. Так, если Ch = Str_In[ i] приводит к нулевому результату (текущему символу присвоен признак конца строки), то цикл будет прекращен и вторая часть условия вычисляться не будет. Если же был переслан из строки ненулевой символ, то будет вычисляться вторая часть условия Yes = (Ch == Str_In[ i+1] ). И если она истинна (символы равны), то отрицание этого приведет к выходу из цикла.

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

    3.5. Процедуры и функции

    В языке Си все процедуры рассматриваются как функции, т.е. все они имеют результат. В редких случаях, когда требуется прямо указать, что функция не возвращает никакого значения, указывается тип void.

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

    При определении функции (процедуры) разработчик должен задать заголовок функции, в котором определяются тип возвращаемого значения, имя функции и список ее параметров, например:

    void My Strcpy(char * Destination, char * Source)  
        

    Такой заголовок определяет процедуру, перемещающую значение входной строки символов Source в результирующую строку Destination. В качестве формальных параметров рассматриваются указатели на символы, что в языке Си также соответствует символьным массивам.

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

    {
      while ( ( Destination++ = *Source++) != '\0' ) ;
    }  
        

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

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

    /***********************************************************
    * Наименование : My_Strcmp
    *
    * Назначение : Функция сравнивает значения двух символьных
    *     строк и возвращает TRUE, если они равны,
    *     и FALSE в противном случае.
    *
    * Входы : String1  String2–две строки для сравнения.
    *
    * Выходы : Нет
    *
    * Возвращаемое значение :
    *
    *   TRUE   Строки одинаковые
    *   FALSE   Строки различаются
    ***********************************************************/
    BOOL My_Strcmp( CHAR *String1, CHAR *String2 )
    {
      while ( *String1 != '\0' )  
      {
      if ( *String1++ != *String2++ )
        { /* обнаружено несовпадение */
        return FALSE ;
        }
      }
        /* Дошли до конца строки. Надо проверить, что концы совпали */
        return ( *String1 == *String2 ) ;
    }
        

    3.6. Управление памятью

    Для лучшего понимания механизмов связи контекста вызова и контекста описания процедур рассмотрим модель отражения программных объектов (переменных и операторов) в оперативную память вычислительной машины. Предлагаемая модель предполагает множество упрощений, но позволяет лучше понять, что происходит при подготовке программы к выполнению и собственно в процессе работы программы.

    Будем считать, что для работы программного кода в оперативной памяти машины выделяется непрерывный сегмент - рабочая область (РО) между адресами L_addr и H_addr. В процессе трансляции и последующей сборки программных модулей в исполняемый машинный код строятся два сегмента: сегмент программного кода (СК) и сегмент данных (СД). Будем считать, что непосредственно перед процессом выполнения программы оба эти сегмента загружаются в память машины, как это показано на рис. 3.1.

    (рис 3.1) Модель размещения программы в памяти машины

    Область, расположенная между памятью, занятой СК и СД, представляет собой пространство так называемой динамической памяти (ДП). Программа может использовать ее в процессе вычислений, запрашивая у системы поддержки по ходу вычислений. Для этой цели обычно используется процедура с именем malloc, в качестве параметров которой указывают размер запрашиваемого участка и получают в ответ адрес выделенной памяти из области ДП.

    Если выделенная память больше не нужна, то ее можно возвратить, указав процедуре free адрес освобождаемого фрагмента. Из-за того, что отведение и освобождение памяти в ДП происходит по запросам программы, ее еще часто называют управляемой, а программисты-инструментальщики используют термин "куча". Последнее подчеркивает тот факт, что при произвольном характере занятия и освобождения кусков ДП она постепенно приобретает "дырявый" вид. В ней участки занятой памяти перемежаются освободившимися фрагментами различного размера. Управлять эффективным использованием этого пространства весьма сложно. Мы не будем далее углубляться в эти вопросы. Куда более интересно рассмотреть работу с областями СК и СД.

    В процессе трансляции компилятор, обрабатывая программный модуль, может построить только сегмент кода тех процедур, которые описаны в нем. В процессе сборки нескольких модулей редактор связей, включенный в состав системы программирования, просматривает код головного модуля (с процедурой main), выделяет в нем обращения к внешним процедурам (таким, как printf, scanf, getch и т.п.) и присоединяет к сегменту головного модуля сегмент кода модуля вызываемых внешних процедур (например, stdio). Далее процедура повторяется по отношению к присоединенному модулю. В итоге структура ДК фактически образует стек сегментов кода собранных модулей (табл. 3.2), необходимых для решения задачи. Стек формируется в процессе подготовки кода к выполнению (сборки программного сегмента) и не изменяется во время решения задач. В этом смысле он статичен.

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

    Формирование остальной части области переменных происходит в чистом виде по принципу работы стека (последний пришел - первый ушел). Фактически происходит следующее. При обращении к процедуре i+1-го уровня с прототипом

    void Fill(char Ch, int K, char *Array);  
        

    (будем считать, что процедура main находится на нулевом уровне) в контексте вызова (в вызывающей процедуре) к сформированному на i-м уровне стеку данных добавляются поля данных, размер которых определяется списком передаваемых параметров. Скажем, 1 байт для символа, 4 байта для целого значения, 8 байт для указателя (табл. 3.2).

    Модель распределения памяти под пространство параметров вызываемой процедуры
    0 1 2 3 4
    стек i-го уровня char int *char свободная память
    0 1 2 3 4 5 6 7 8 9 10 11 12

    Далее формируются значения полей 1, 2 и 3 в отведенной области. А в поля 1 и 2 пересылаются значения соответствующих фактических параметров вызова. Если предположить, что процедура Fill обеспечивает заполнение K байт передаваемой в Array области символами Ch, то обращение к этой процедуре может выглядеть так:

    void main()
    { int i;
      char Filler; 
      char String[100];
      i = 20;
      Filler = '*';
    . . . . . .
      Fill(Filler, i, String);
    . . . . . .
    }
        

    Такой вызов приведет к тому, что в поле 1 появится символ "*", в поле 2 - значение "20", а в третьем поле будет сформирован адрес нулевого элемента массива String. Предположим, что сама процедура Fill написана следующим образом:

    void Fill(char Ch, int K, char *Array)
    { int i;
      Char *Pointer;
      for (i = 0, Pointer = Array; i < K; i++)
        *Pointer++ = Ch;
    }  
        

    Тогда при входе в процедуру произойдет следующее. В области свободной памяти (табл. 3.2) будет выделен 12-байтовый фрагмент под размещение локальных переменных i и Pointer (4 и 8 байта). В соответствии с кодом процедуры в поле Pointer будет скопирован адрес массива String головной процедуры. В дальнейшем по этому адресу будут пересылаться символы "*" из поля 1 (*Pointer++ = Ch;), причем каждый раз адрес будет увеличиваться на единицу.

    Таким образом, в распоряжении процедуры Fill во время ее выполнения в стеке данных доступны поля с 1-го по 5-е (табл. 3.3) с общей длиной 25 байт. Нулевая область на рисунке по-прежнему соответствует области стека i-го уровня, 6-я - свободной области памяти, а вершиной стека считается адрес 24-го байта выделенного фрейма.

    Фрейм стека данных процедуры Fill
    0 1 2 3 4 5 6
    стек i-го уровня char K Array i Pointer свободная память
    0 1 ... 4 5 ... 12 13 ... 16 17 ... 24

    Поскольку головная программа передала в процедуру адреса своих областей данных, то Fill получает возможность отправлять значения в область адресов стека i-го уровня. Фактически это соответствует передаче параметров по имени (по ссылке) - способу, характерному для случая, когда в результате работы вызываемой процедуры происходит изменение значений передаваемых ей параметров.

    По завершению работы процедуры Fill добавленный фрейм (и область параметров, и область локальных переменных) освобождается и вершиной стека снова является последний байт нулевой области (табл. 3.3).

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

    Вопросы и задачи для самостоятельного решения

  • Напишите процедуру проверки того, что шкаф с параметрами высота H, ширина W, длина L можно занести в комнату через дверь размером P на Q.
  • Чему будет равен х после выполнения этих строчек:
    х=5; х-= х++ - —х;  
          
  • Реализуйте на языке Си функцию конкатенации двух строк.
  • В int-представлении чисел можно обменять значения двух переменных, не используя промежуточной памяти:

    int I, J;
    ...
    I = I + J; J = I – J; I = I – J;  
          

    Справедливо ли подобное преобразование для float-представления?

  • Реализуйте на языке Си функцию замены подстроки в строке на заданную последовательность символов.
  • Реализуйте на языке Си следующую задачу: "Заменить в исходной строке все цифры на соответствующее количество символов "* ", равное значению цифры (0 заменять на пустую подстроку, 9 - на *********). Строка передается процедуре-функции как входной/ выходной параметр". Рекомендуемое имя для процедуры - Convert String.
  • Страницы:

    Язык Си подобен обоюдоострому лезвию, при помощи которого легко создать как изысканное блюдо, так и кровавое месиво.

    Язык Си был создан в 1972 г. Деннисом Ритчи в ходе разработки операционной системы (ОС) Unix. Практически вся ОС была написана на Си. Большие выразительные возможности и легкость расширения способствовали быстрому распространению этого языка. Компиляторы для языка Си доступны практически для всех существующих на данный момент ОС и платформ.

    В отличие от большинства языков высокого уровня (Ада, Алгол и др.), которые начинали применять только после принятия соответствующих стандартов на них, язык Си был создан как рабочий инструмент эффективного кодирования и не претендовал на общественное использование. Спецификация языка была определена в книге Кернигана Б. и Ритчи Д. (перевод на русский язык [8]). Эта книга оставалась неформальным стандартом на язык Си вплоть до 1989 г., когда был принят стандарт ANSI. На данный момент существует еще ISO-стандарт на этот язык.

    В качестве отличительных особенностей языка Си можно выделить:

  • препроцессорные директивы;
  • необходимость определять переменные, но возможность это делать в любом месте (до первого использования переменной);
  • использование фигурных скобок {} для структуризации текста программы;
  • обязательное наличие функции main()
  • 3.1. Типы в языке Си

    Как и в любом языке программирования, в Си определены базовые типы и набор механизмов для задания произвольных пользовательских типов на основе базовых.

    Базовые типы

    В качестве базовых в языке Си определены следующие типы.

    Целочисленные:

  • char - целый длиной не менее 8 бит (интерпретируется обычно как литера);
  • short /short int/ - короткий целый;
  • int - целый;
  • long - длинный целый.
  • Для всех целочисленных типов можно определить, будут они знаковые или нет. Это делается прибавлением слова unsigned или signed перед названием типа (по умолчанию - signed).

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

    Более удачным оказалось представление отрицательных чисел в виде дополнения, поскольку одна и та же схема могла выполнять и сложение, и вычитание. Некоторые разработчики выбирали дополнение до единицы, когда $$-n$$ получалось из $$n$$ путем простого инвертирования всех бит (так называемый обратный код). Другие выбирали дополнение до двойки, когда $$-n$$ получалось путем инвертирования всех бит и прибавлением единицы. Недостатком первого способа было наличие двух форм представления нуля (0...0 и 1...1). Это неприятно, особенно если доступные инструкции сравнения являются неадекватными. Различия этих форм показаны в табл. 3.1.

    Представление в прямом, обратном и дополнительных кодах
    Десятичное представление Прямой код Дополнительный код Обратный код
    Знак Модуль
    2 0 010 010 010
    1 0 001 001 001
    0 0 000 000 000 или 111
    -1 1 001 111 110
    -2 1 010 110 101

    Сегодня во всех компьютерах используется арифметика с дополнением до двойки, обычно называемая просто дополнительным кодом. Как мы видим, у отрицательных чисел старший разряд равен 1. Добавление спецификации unsigned означает, что весь код следует воспринимать как модуль числа. При этом нижняя строчка таблицы, соответствующая -2 в десятичной системе и представляющая в дополнительном коде 110, будет обрабатываться как десятичное число шесть. Естественно, при signed-спецификации этот же код будет обрабатываться как - 2.

    Числа с дробной частью можно представлять в формах с фиксированной и плавающей точкой. В современной аппаратуре для вещественных чисел обычно поддерживается арифметика с плавающей точкой, т.е. число $$x$$ представляется двумя целыми числами - порядком $$e$$ и мантиссой $$m$$, так что $$x=2^{e}m$$. Это позволяет значительно расширить диапазон представляемых в машине чисел.

    Вещественные:

  • float - вещественный одинарной точности;
  • double - вещественный двойной точности;
  • long double - вещественный максимальной точности.
  • Недостатком такого представления является то, что та же память теперь делится между двумя целыми числами: мантиссой числа и его порядком. Это приводит к сокращению числа разрядов мантиссы, а следовательно, к меньшей ее точности. Более того, практически одно и то же число может теперь быть представлено разной комбинацией разрядов: чуть большим или чуть меньшим значением. Т.е. сравнение чисел надо производить с учетом некоторого диапазона чувствительности. Стоит при этом помнить еще и о том, что складывая очень большие и очень маленькие вещественные числа из различных диапазонов, каждое из которых помещается в машинное представление, мы рискуем потерять младшие разряды мантиссы, а значит, и значение малой добавки.

    Разработчики аппаратуры шли на различные ухищрения для смягчения этого свойства: отказывались от хранения старшего разряда мантиссы для увеличения ее точности, переходили к удвоенной разрядной сетке, увеличивали основание порядка до 8 и даже до 16. Однако проблемы округления оставались. Известны случаи, когда в подобных машинах можно было найти такие значения x и у, что для некоторого малого положительного ? выполнялось соотношение $$(х + \varepsilon ) x(y + \varepsilon )<(x \times y)$$, т.е. умножение утрачивало свойство монотонности.

    Соглашения об именах

    Мы уже говорили, обсуждая программную документацию, что для текстов программ характерно однократное написание и многократное прочтение. Весьма значимым при чтении текста программы является отделение имен одного класса объектов от других. Проще всего для этого ввести некоторые соглашения об именах, например имена типов и констант писать ТОЛЬКО заглавными буквами, начинать определение имени без знаковых типов и буквы U (unsigned), перечислимых типов - с буквы E (enumeric) и тому подобное. Для этих целей можно воспользоваться следующими определениями:

    typedef char   CHAR; /* general text. Range 0..127 */
    typedef unsigned char UCHAR; /* extended text. Range 0..255 */
    typedef unsigned short BOOL; /* Time efficient Boolean */
    typedef unsigned har TBOOL; /* Tiny (space efficient) Boolean */
    typedef char   TINY; /* 8 bit. Range (-2^7-1)..(+2^7) */
    typedef unsigned char UTINY; /* 8 bit. Range (0)..(+2^8) */
    typedef unsigned char TINYBITS; /* 8 bit. For bit manipulation use */
    typedef short SHORT; /* 16 bit. Range (-2^15-1)..(+2^15) */
    typedef unsigned short USHORT; /* 16 bit. Range (0)..(+2^16) */
    typedef unsigned short SHORTBITS; /* 16 bit. For bit manipulation use */
    typedef long LONG; /* 32 bit.Range (-2^31-1)..(+2^31)*/
    typedef unsigned long ULONG; /* 32 bit.Range (0)..(+2^32) */
    typedef unsigned long LONGBITS; /* 32 bit.For bit manipulation use */
    typedef int INT; /* Natural machine size integer */  
          

    Как мы видим, один и тот же тип языка Си, например unsigned char, может быть многократно переопределен под различными именами в зависимости от целей его использования.

    Типы, определяемые пользователем

    Программисту позволяется вводить в программу свои собственные средства кодировки, приписывая каждому значению собственное имя (идентификатор). Для этого в языке Си введен перечислимый тип.

    Перечислимые:

    enum - перечисление констант, например:

    enum { Yellow=5, Cyan, Magenta};  
          

    или, если следовать правилу использования в именах констант только заглавных букв:

    enum { YELLOW=5, CYAN, MAGENTA};  
          

    Здесь после первого элемента стоит знак "=" и за ним число, это число определяет, с какого значения идет нумерация перечисленных констант (следующая константа будет иметь значение на единицу больше). Если не указывать это начальное число, то нумерация констант будет идти от нуля. Таким образом, к перечисленным константам можно обращаться по их номерам, а можно задать и полностью свою систему обозначений:

    typedef enum 
    {
      START END FLAG    = 0x0001,  /*  Event  Flag  0  * /
      TEST END FLAG    = 0x0002,  /*  Event  Flag  1  * /
      SYS INIT END    = 0x0004,  /*  Event  Flag  2  */
      CODEC CODE DOWNLOADED =  0x0008,  /*  Event  Flag  3  */
      MODEM CODE DOWNLOADED =  0x0010  /*  Event  Flag  4  */
    } E_RUN_FLAG;
      
          

    Такое определение перечислимого типа позволяет закодировать значения единицы в разрядах с нулевого по четвертый соответственно. Важно только помнить, что константы типа перечисляются ВСЕГДА в порядке возрастания их значений. И при соблюдении ранее указанных правил наименований если вы где-либо в программе встретите строку определения:

    E_RUN_FLAG My_Indicator;  
          

    то вы легко догадаетесь, что переменная My_Indicator имеет перечислимый тип.

    Ключевое слово void определяет отсутствие типа и используется для "нейтрализации" значения, возвращаемого функцией [9].

    Сложные типы

    В языке Си определены следующие механизмы для задания сложных типов:

  • массивы;
  • структуры;
  • указатели;
  • строки;
  • объединения.
  • Массивы

    Массивы представляют собой однородную последовательность элементов, каждый из которых характеризуется порядковым номером в данной последовательности. Номер элемента указывается вслед за идентификатором последовательности (идентификатором массива) в квадратных скобках: My_Array[i] , Your_Array[5] и т.п. Самый первый элемент массива имеет порядковый номер 0 (нуль).

    Объявление массива в программе в простейшем случае может выглядеть так:

    int My_Vector[3] ;  
          

    где константа 3 указывает, что последовательность, представляемая массивом, содержит три элемента: My_Vector [0], My_Vector [1], My Vector [2].

    При этом следует помнить, что упоминание в программе самого идентификатора массива практически эквивалентно указанию адреса его нулевого элемента.

    Записи

    Записи (или структуры) представляют собой набор полей, объединенных вместе. Можно работать и с каждым полем отдельно, и со всеми полями, как с записью, вместе.

    Вот пример задания структуры:

    struct goods 
      {
        char * name; 
        long price; 
        int volume;
      };
          

    Здесь goods - имя структурного типа. В этом случае объявление переменной food такого типа будет иметь вид:

    struct goods food;  
          

    Если же объявить структуру как:

    typedef struct 
      {
        char * name; 
        long price; 
        int volume;
      } GOODS;
          

    то можно будет объявлять переменные, используя только имя типа:

    GOODS food;  
          

    Язык Си разрешает присваивание структур (в отличие от присваивания массивов, которое может быть выполнено только поэлементным присваиванием).

    При объявлении переменной структурного типа можно проинициализировать ее значение при помощи следующей конструкции:

    GOODS food = {"milk", 25.6, 1} ;  
          

    Для работы с полями в языке используется составное имя с точкой:

    food.price = 27.5;  
          

    Указатели

    Указатели в языке Си объявляются при помощи символа "*":

    int * pInt;  
          

    Здесь переменная pInt - это указатель на int или, проще, адрес переменной целого типа. Доступ к значению, хранящемуся по адресу указателя, производится через разыменование:

    *pInt=6;  
          

    Забавно, что если написать обе строки (объявление указателя и присваивание) подряд, то получится синтаксически правильный фрагмент, но по смыслу он ошибочен. Объявлением указателя мы выделили память под сам указатель, т.е. под адрес некоторого объекта. Однако память под сам объект не выделена, и поэтому записывать значение 6 некуда.

    При помощи оператора "" можно получить адрес уже существующей переменной. Следующий пример демонстрирует, как можно установить указатель pint на переменную varlnt типа int:

    int * pint; 
    int varint; 
    pint = varInt;  
          

    После этого уже можно выполнять присваивание *plnt=6;. Теперь значение 6 попадет в память переменной varint.

    Указатель, "не указывающий никуда", в языке Си называется Null. Это единственная неарифметическая константа. Иногда она представлена как NULL.

    Сами по себе указатели не являются сложными типами с точки зрения обращения к ним. Их значением является адрес некоторого программного объекта. Как правило, они указывают на некоторый тип, который в свою очередь уже может иметь сложную составную структуру. Специфика работы с указателями заключается в том, что, например, префиксная или постфиксная операция ++ будет изменять значение указателя на 1, 2, 4 или некоторую другую величину в зависимости от длины (количеств байт, отведенных для представления) того типа, на который указывает этот указатель.

    Еще одной и наиболее важной проблемой указателей является несогласованность времени жизни указателя (адреса объекта) и программного объекта, на который он указывает.

    Например, функция сохраняет адрес некоторой своей локальной переменной в глобальном указателе. По выходу из функции памяти объекта уже нет. Она может быть перераспределена каким угодно образом, а адрес, где "все это" лежало до сих пор, существует и может быть использован для обращения.

    Ошибки подобного рода очень трудно обнаружить, поскольку до некоторого времени поведение программы с дефектами такого вида может быть весьма правдоподобным. Это происходит до тех пор, пока "отданная" память никем не переопределялась, а потом такая программа "вдруг" перестает работать.

    Строки

    Для работы со строками в Си не введено специального типа, поэтому для представления элементов строки используется символьный тип. Принято, что строка - это массив символов (байт), заканчивающийся нулевым байтом ('\0'). Соответственно, пустая строка начинается именно с этого символа (байта с нулевым кодом).

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

    Объединения

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

    Пример определения типа объединения:

    union Big Word
    {
      long Bg long; 
      char *Bg chart [4] ;
    }
          

    А переменная X_Var такого типа определяется следующим образом:

    union Dig Word X_Var;  
          

    Данные типа union Big_Word занимают память, необходимую для размещения наибольшего из своих элементов, и могут рассматриваться как аналог вариантной записи в языке Паскаль.

    Константы

    Константы в языке Си могут иметь одинаковые значения при различных формах своего представления. В особенности это относится к числовым значениям.

    10, 012 и 0xA в языке Си будут соответствовать одному и тому же значению 10 или в двоичной форме 1010. Просто числовые константы, начинающиеся с цифры 0, считаются в Си заданными в восьмеричной форме представления, а с префиксом 0х - шестнадцатеричными.

    Вещественные числа также можно записывать в различных формах 12.5, 1.2e1, 125e-1, где символ "е" или "Е" используется для отделения мантиссы числа от порядка. Предполагается, что вещественная константа всегда положительна, а предшествующий ей знак "минус", если указан, рассматривается как унарная операция смены знака.

    Еще следует обратить внимание на символьные константы. Они записываются в одиночных кавычках: 'A', 'a', ' + ', '-' и т.п. В случае необходимости можно определить символ его кодом в таблице '\12' или '\xA', задавая после обратной наклонной черты номер символа в восьмеричной или шестнадцатеричной системе.

    Ранее говорилось, что следует различать символы и строки символов, которые задаются двойными кавычками. Так, константе "A" будет соответствовать строка из двух символов: собственно 'A' и '\0', т.е. к байту символа будет добавлен второй байт с нулевым значением для представления признака конца строки.

    3.2. Унарные операторы

    Кроме бинарных операторов, в языке Си существует достаточно большое число унарных операторов.

    Например, допустима следующая запись:

    х=9; —х++++;  
        

    После ее выполнения х будет равен 10. Два минуса слева означают уменьшение значения х на единицу до использования значения переменной в выражении, два раза по два плюса после х означают два раза увеличить х на единицу после использования значения х в расчете выражения.

    Попробуйте, например, рассчитать, чему будет равен х после выполнения следующих строчек на языке Си:

    int x = 1;
    x += x++ + ++x;
        

    (запись х + = 5 эквивалента записи х = х + 5).

    Любопытно, что некоторые компиляторы не умеют правильно интерпретировать подобную "мешанину" из унарных и бинарных операторов в выражении с одной переменной. Тем более, стоит избегать подобных форм записи, сложных для интерпретации. Это не только дополнительная потеря времени, но и потенциальный источник ошибок.

    3.3. Средства низкого уровня

    Как уже было сказано, адрес любой переменной получается при помощи операции "", указанной перед именем этой переменной. С результатом такой операции можно производить все арифметические действия, доступные для целых чисел.

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

    Как и в большинстве языков программирования, Си допускает переопределение типов, в ходе которого никаких преобразований внутреннего представления не происходит, однако появляется возможность работать с одним типом как с другим. Для этого достаточно перед переменной поставить в скобках название нового типа:

    int iv; char cv; cv=(char)iv;  
        

    Кроме работы с адресами и преобразования типов, в языке Си предусмотрен набор битовых операций:

    - поразрядная конъюнкция;

    ^ - поразрядное исключающее или;

    | - поразрядная дизъюнкция;

    << - поразрядный сдвиг влево;

    >> - поразрядный сдвиг вправо.

    Не стоит их путать с операциями логическими:

    - логическая конъюнкция;

    ! - отрицание;

    || - логическая дизъюнкция.

    Так, 21 дадут результат 0 (ложно), 2|1 дадут результат 3 (истинно), а 21 дадут 1 (истинно) и 2||1 тоже дадут 1 (истинно).

    Поразрядное кодирование

    В ряде случаев для представления кодовых значений удобно давать наименования группам разрядов машинного слова или даже отдельным разрядам. Обычно такие значения трактуются как беззнаковые (unsigned) числа. Подобные описания в Си описываются как структуры с битовыми полями. Например:

    struct STATUS_FLAG
      {
        unsined Overflow :1;
        unsined Start_Up :1;
        unsined Source_Code :4;
        unsined Result_Id :2;
    } In_Out_Status;  
        

    Эта структура описывает 8-битовый байт, разделенный на четыре поля. Первые два из них являются отдельными битами со значениями 0 или 1. Третье поле Source_Code представляет собой нормальное 4-разрядное шестнадцатеричное число, а последнее поле (два разряда) может представлять коды от 0 до 3.

    3.4. Управление вычислениями

    В язык Си включен достаточно развитый набор средств, позволяющий управлять вычислительным процессом и производить те или иные действия в зависимости от обрабатываемых данных.

    Условные выражения и условные операторы

    Например, нам необходимо проверить, что устройство присоединено к системе, для чего проверяется значение логической функции Check_For_Connection ( ). При благоприятных условиях (устройство подключено) мы должны выставить флаг передачи данных Transmit равным единице. Программный код может выглядеть так:

    if (Check For Connection() == TRUE)
      {
        Transmit = 1;
      }
      else
      {
        Transmit = 0;
      }  
          

    То же самое будет, если мы вспомним, что функция Check_For_Connection ( ) сама имеет логическое значение.

    if (Check For Connection())
      {
        Transmit = 1;
      }
      else
      {
        Transmit = 0;
      }
          

    Или, используя условное присваивание:

    Transmit = Check For Connection()?1:0;  
          

    и наконец:

    Transmit = Check For Connection();  
          

    В последнем случае мы "вспомнили", что логическое значение "истинно" представляется в языке Си единицей, а "ложно" - нулем. Мы специально не воспользовались средствами приведения (преобразования) типа, чтобы добиться максимальной лаконичности записи.

    Однако эта последняя запись и самая неудобная в смысле сопровождения программного кода. Он стал наименее читаемым и сложно изменяемым при изменении соглашений о связях. С другой стороны, хорошо оптимизирующий компилятор во всех четырех случаях может построить одинаковый код машинной программы.

    С точки зрения понимания того, что делается в программе, первая запись является наиболее удачной. Она же наиболее пригодна для тестирования, так как ветви вычислений в ней явно разделены. Кроме того, даже возвращаемое функцией Check_For_Connection () значение проверяется в отдельной операции сравнения.

    Операторы цикла

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

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

    Давайте посмотрим на фрагмент программы, определяющий, есть ли в строке, хранящейся в массиве char Str_In [20], стоящие подряд повторяющиеся символы. Напомним, что признаком конца строки в языке Си является символ с кодом '\0'.

    int i, Yes; 
    char Ch;
    i = 0; Ch = Str In[ i] ; Yes = 0; 
    while ( Ch != '\0' )
      { if ( Ch == Str_In[ ++i]  )
        Yes = 1;
        Ch  = Str In[ i] ;
      }  
          

    Такой цикл будет работать до тех пор, пока не "наткнется" на конец строки. И значение переменной Yes, равное единице, покажет нам, встречались ли в строке стоящие подряд одинаковые символы. Но цикл можно прекратить и раньше, если в инвариант включить проверку значения переменной Yes:

    int i, Yes; 
    char Ch;
    i = 0; Ch = Str In[ i] ; Yes = 0; 
    while ( Ch != '\0'  Yes == 0 )
      {
        Yes = ( Ch == Str_In[ ++i] ) ;
        Ch  = Str In[ i] ;
      }
          

    Теперь цикл будет прекращаться, как только в строке встретится пара одинаковых символов. В этом случае и тело удалось упростить, так как если соседние символы равны, то переменная Yes становится истинной и цикл прекращается.

    Используя форму цикла do-while или, как его еще называют, цикл с постусловием, можно еще сократить текст подобного фрагмента программы:

    int i, Yes; 
    char Ch;   
    i = 0; Ch = Str In[ i] ; 
    do
      { Yes = ( Ch == Str_In[ ++i]  )  ;
      Ch = Str In[ i] ;
      }
    while ( Ch != '\0'  Yes == 0 );
          

    Как видим, удалось обойтись без начальной инициализации переменной Yes, поскольку инвариант цикла в подобном операторе вычисляется уже после того, как тело цикла выполнилось один раз и, следовательно, значение переменной Yes уже определилось.

    Еще один вид цикла имеет заголовок, определяющий более сложные правила выполнения тела. Это цикл for. В заголовке этого цикла в круглых скобках записываются три блока операторов. Первый (инициализация цикла) выполняется один раз перед началом работы. Второй - инвариант цикла, определяющий предусловие выполнения тела. Третий оператор выполняется в цикле сразу после тела. Фактически он является завершением тела - его последним оператором.

    Проще всего проиллюстрировать этот вид цикла на примере обнуления первых 20 элементов некоторого массива My_Array:

    int i;
    for (i = 0;i<20; My Array[ i++] = 0 ) ;
          

    В данном примере собственно определено пустое тело. Все его полезные действия (обнуление очередного элемента массива и увеличение индекса - параметра цикла) уложились в третий элемент заголовка. Более сложный и более "классический" пример использования цикла for приведен ниже.

    Пусть мы имеем некоторый 8-битовый код в переменной

    unsigned char Input Byte  
          

    и наша задача - поменять местами биты этого кода. Попробуем применить для этого следующий фрагмент программы:

    unsigned char Reversed Byte; 
    unsigned char i;
    Reversed Byte = 0;
    for (i=0; i < 8; i++)
    {
      Reversed Byte <<= 1;  /*  Shift output byte left.  */
      if (Input Byte  1)  /*  If input byte has 1 in the  first  */
      Reversed_Byte |= 1;  /*  pos. put 1 into the output  byte.  */
      Input Byte >>=1;  /*  Shift input byte right for the  */
    }  /* next bit */  
          

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

    Reversed_Byte |= Input_Byte  1;  
          

    Причем в данном случае краткость не ухудшит понимание программного кода, потому что и левая, и правая части оператора присваивания имеют общую битовую структуру.

    С помощью цикла for можно записать и решение задачи по проверке наличия одинаковых символов в строке:

    int i, Yes; 
    char Ch;
    for (i = 0, Ch = Str_In[ i] ; Ch != '\0'  Yes == 0;)
    {
      Yes = ( Ch == Str_In[ ++i]  )  ;
      Ch = Str In[ i] ;
    }  
          

    За счет эквивалентности значений некоторых типов (символьных, целых и логических) можно найти более короткую запись самого цикла:

    for (i = 0 ; Ch = Str_In[ i]  ! Yes = (Ch == Str_In[ i+1] ); ++i);  
          

    В этой записи используется следующее свойство: условие инварианта цикла вычисляется до тех пор, пока не будет установлена его истинность или ложность. Так, если Ch = Str_In[ i] приводит к нулевому результату (текущему символу присвоен признак конца строки), то цикл будет прекращен и вторая часть условия вычисляться не будет. Если же был переслан из строки ненулевой символ, то будет вычисляться вторая часть условия Yes = (Ch == Str_In[ i+1] ). И если она истинна (символы равны), то отрицание этого приведет к выходу из цикла.

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

    3.5. Процедуры и функции

    В языке Си все процедуры рассматриваются как функции, т.е. все они имеют результат. В редких случаях, когда требуется прямо указать, что функция не возвращает никакого значения, указывается тип void.

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

    При определении функции (процедуры) разработчик должен задать заголовок функции, в котором определяются тип возвращаемого значения, имя функции и список ее параметров, например:

    void My Strcpy(char * Destination, char * Source)  
        

    Такой заголовок определяет процедуру, перемещающую значение входной строки символов Source в результирующую строку Destination. В качестве формальных параметров рассматриваются указатели на символы, что в языке Си также соответствует символьным массивам.

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

    {
      while ( ( Destination++ = *Source++) != '\0' ) ;
    }  
        

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

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

    /***********************************************************
    * Наименование : My_Strcmp
    *
    * Назначение : Функция сравнивает значения двух символьных
    *     строк и возвращает TRUE, если они равны,
    *     и FALSE в противном случае.
    *
    * Входы : String1  String2–две строки для сравнения.
    *
    * Выходы : Нет
    *
    * Возвращаемое значение :
    *
    *   TRUE   Строки одинаковые
    *   FALSE   Строки различаются
    ***********************************************************/
    BOOL My_Strcmp( CHAR *String1, CHAR *String2 )
    {
      while ( *String1 != '\0' )  
      {
      if ( *String1++ != *String2++ )
        { /* обнаружено несовпадение */
        return FALSE ;
        }
      }
        /* Дошли до конца строки. Надо проверить, что концы совпали */
        return ( *String1 == *String2 ) ;
    }
        

    3.6. Управление памятью

    Для лучшего понимания механизмов связи контекста вызова и контекста описания процедур рассмотрим модель отражения программных объектов (переменных и операторов) в оперативную память вычислительной машины. Предлагаемая модель предполагает множество упрощений, но позволяет лучше понять, что происходит при подготовке программы к выполнению и собственно в процессе работы программы.

    Будем считать, что для работы программного кода в оперативной памяти машины выделяется непрерывный сегмент - рабочая область (РО) между адресами L_addr и H_addr. В процессе трансляции и последующей сборки программных модулей в исполняемый машинный код строятся два сегмента: сегмент программного кода (СК) и сегмент данных (СД). Будем считать, что непосредственно перед процессом выполнения программы оба эти сегмента загружаются в память машины, как это показано на рис. 3.1.

    (рис 3.1) Модель размещения программы в памяти машины

    Область, расположенная между памятью, занятой СК и СД, представляет собой пространство так называемой динамической памяти (ДП). Программа может использовать ее в процессе вычислений, запрашивая у системы поддержки по ходу вычислений. Для этой цели обычно используется процедура с именем malloc, в качестве параметров которой указывают размер запрашиваемого участка и получают в ответ адрес выделенной памяти из области ДП.

    Если выделенная память больше не нужна, то ее можно возвратить, указав процедуре free адрес освобождаемого фрагмента. Из-за того, что отведение и освобождение памяти в ДП происходит по запросам программы, ее еще часто называют управляемой, а программисты-инструментальщики используют термин "куча". Последнее подчеркивает тот факт, что при произвольном характере занятия и освобождения кусков ДП она постепенно приобретает "дырявый" вид. В ней участки занятой памяти перемежаются освободившимися фрагментами различного размера. Управлять эффективным использованием этого пространства весьма сложно. Мы не будем далее углубляться в эти вопросы. Куда более интересно рассмотреть работу с областями СК и СД.

    В процессе трансляции компилятор, обрабатывая программный модуль, может построить только сегмент кода тех процедур, которые описаны в нем. В процессе сборки нескольких модулей редактор связей, включенный в состав системы программирования, просматривает код головного модуля (с процедурой main), выделяет в нем обращения к внешним процедурам (таким, как printf, scanf, getch и т.п.) и присоединяет к сегменту головного модуля сегмент кода модуля вызываемых внешних процедур (например, stdio). Далее процедура повторяется по отношению к присоединенному модулю. В итоге структура ДК фактически образует стек сегментов кода собранных модулей (табл. 3.2), необходимых для решения задачи. Стек формируется в процессе подготовки кода к выполнению (сборки программного сегмента) и не изменяется во время решения задач. В этом смысле он статичен.

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

    Формирование остальной части области переменных происходит в чистом виде по принципу работы стека (последний пришел - первый ушел). Фактически происходит следующее. При обращении к процедуре i+1-го уровня с прототипом

    void Fill(char Ch, int K, char *Array);  
        

    (будем считать, что процедура main находится на нулевом уровне) в контексте вызова (в вызывающей процедуре) к сформированному на i-м уровне стеку данных добавляются поля данных, размер которых определяется списком передаваемых параметров. Скажем, 1 байт для символа, 4 байта для целого значения, 8 байт для указателя (табл. 3.2).

    Модель распределения памяти под пространство параметров вызываемой процедуры
    0 1 2 3 4
    стек i-го уровня char int *char свободная память
    0 1 2 3 4 5 6 7 8 9 10 11 12

    Далее формируются значения полей 1, 2 и 3 в отведенной области. А в поля 1 и 2 пересылаются значения соответствующих фактических параметров вызова. Если предположить, что процедура Fill обеспечивает заполнение K байт передаваемой в Array области символами Ch, то обращение к этой процедуре может выглядеть так:

    void main()
    { int i;
      char Filler; 
      char String[100];
      i = 20;
      Filler = '*';
    . . . . . .
      Fill(Filler, i, String);
    . . . . . .
    }
        

    Такой вызов приведет к тому, что в поле 1 появится символ "*", в поле 2 - значение "20", а в третьем поле будет сформирован адрес нулевого элемента массива String. Предположим, что сама процедура Fill написана следующим образом:

    void Fill(char Ch, int K, char *Array)
    { int i;
      Char *Pointer;
      for (i = 0, Pointer = Array; i < K; i++)
        *Pointer++ = Ch;
    }  
        

    Тогда при входе в процедуру произойдет следующее. В области свободной памяти (табл. 3.2) будет выделен 12-байтовый фрагмент под размещение локальных переменных i и Pointer (4 и 8 байта). В соответствии с кодом процедуры в поле Pointer будет скопирован адрес массива String головной процедуры. В дальнейшем по этому адресу будут пересылаться символы "*" из поля 1 (*Pointer++ = Ch;), причем каждый раз адрес будет увеличиваться на единицу.

    Таким образом, в распоряжении процедуры Fill во время ее выполнения в стеке данных доступны поля с 1-го по 5-е (табл. 3.3) с общей длиной 25 байт. Нулевая область на рисунке по-прежнему соответствует области стека i-го уровня, 6-я - свободной области памяти, а вершиной стека считается адрес 24-го байта выделенного фрейма.

    Фрейм стека данных процедуры Fill
    0 1 2 3 4 5 6
    стек i-го уровня char K Array i Pointer свободная память
    0 1 ... 4 5 ... 12 13 ... 16 17 ... 24

    Поскольку головная программа передала в процедуру адреса своих областей данных, то Fill получает возможность отправлять значения в область адресов стека i-го уровня. Фактически это соответствует передаче параметров по имени (по ссылке) - способу, характерному для случая, когда в результате работы вызываемой процедуры происходит изменение значений передаваемых ей параметров.

    По завершению работы процедуры Fill добавленный фрейм (и область параметров, и область локальных переменных) освобождается и вершиной стека снова является последний байт нулевой области (табл. 3.3).

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

    Вопросы и задачи для самостоятельного решения

  • Напишите процедуру проверки того, что шкаф с параметрами высота H, ширина W, длина L можно занести в комнату через дверь размером P на Q.
  • Чему будет равен х после выполнения этих строчек:
    х=5; х-= х++ - —х;  
          
  • Реализуйте на языке Си функцию конкатенации двух строк.
  • В int-представлении чисел можно обменять значения двух переменных, не используя промежуточной памяти:

    int I, J;
    ...
    I = I + J; J = I – J; I = I – J;  
          

    Справедливо ли подобное преобразование для float-представления?

  • Реализуйте на языке Си функцию замены подстроки в строке на заданную последовательность символов.
  • Реализуйте на языке Си следующую задачу: "Заменить в исходной строке все цифры на соответствующее количество символов "* ", равное значению цифры (0 заменять на пустую подстроку, 9 - на *********). Строка передается процедуре-функции как входной/ выходной параметр". Рекомендуемое имя для процедуры - Convert String.
  • Вернуться к учебному плану