Pull to refresh

Книга «Эффективный C. Профессиональное программирование»

Reading time13 min
Views9.9K
image Привет, Хаброжители! Мир работает на коде, написанном на C, но в большинстве учебных заведений программированию учат на Python или Java. Книга «Эффективный С для профессионалов» восполняет этот пробел и предлагает современный взгляд на C. Здесь рассмотрен C17, а также потенциальные возможности C2x. Издание неизбежно станет классикой, с его помощью вы научитесь писать профессиональные и надежные программы на C, которые лягут в основу устойчивых систем и решат реальные задачи.

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

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

Вы познакомитесь с ключевыми концепциями программирования на C и научитесь писать высококачественный код, выполняя упражнения для каждой рассматриваемой темы. Вы узнаете, какие методики рекомендуются для разработки правильного и безопасного кода на C. Обновления и дополнительный материал можно найти на веб-странице этой книги www.nostarch.com/effective_c и на сайте www.robertseacord.com. Если после прочтения вам захочется узнать больше о безопасном программировании на C, C++ или других языках, то, пожалуйста, ознакомьтесь с учебными курсами, которые предлагает NCC Group, пройдя по адресу www.nccgroup.trust/us/our-services/cyber-security/security-training/secure-coding.

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

В главе 1 «Знакомство с C» вы напишете простую программу на C, чтобы научиться использовать функцию main. Мы также рассмотрим несколько разных редакторов и компиляторов.
В главе 2 «Объекты, функции и типы» изложены основы языка, такие как объявление переменных и функций. Вы также познакомитесь с принципами использования базовых типов.

В главе 3 «Арифметические типы» рассказано о двух видах арифметических типов данных: целочисленных и с плавающей запятой.

В главе 4 «Выражения и операции» вы узнаете, что такое операции1, и научитесь писать простые выражения для выполнения операций с объектами различных типов.

В главе 5 «Управляющая логика» вы узнаете, как управлять порядком вычисления отдельных операторов. Для начала мы пройдемся по операторам-выражениям и составным операторам, которые описывают, какую работу нужно проделать. Затем рассмотрим три вида операторов, которые определяют, какие блоки кода выполняются и в каком порядке это происходит: операторы выбора, итерирования и перехода.

В главе 6 «Динамически выделяемая память» вы узнаете, как память динамически выделяется в куче во время работы программы. Она нужна в ситуациях, когда до запуска кода неизвестно, сколько памяти ему понадобится.

В главе 7 «Символы и строки» вы изучите различные кодировки, включая ASCII и Unicode, которые можно использовать для составления строк. Вы научитесь применять устаревшие функции из стандартной библиотеки C, интерфейсы с проверкой ограничений, а также API POSIX и Windows для представления и изменения строк.

В главе 8 «Ввод/вывод» вы научитесь выполнять операции ввода/вывода для чтения и записи данных в терминалы и файловые системы. Ввод/вывод охватывает все пути, которыми информация попадает в программу и покидает ее. Без этого ваш код был бы бесполезным. Мы рассмотрим методики на основе стандартных потоков C и файловых дескрипторов POSIX.

В главе 9 «Препроцессор» вы научитесь использовать препроцессор для подключения файлов, определения объектных и функциональных макросов, а также научитесь выполнять условную компиляцию кода в зависимости от свойств той или иной реализации.

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

В главе 11 «Отладка, тестирование и анализ» описываются инструменты и методики для создания корректных программ, включая утверждение времени компиляции и выполнения, отладку, тестирование, статический и динамический анализ. Мы также обсудим, какие флаги компиляции рекомендуется использовать на разных этапах процесса разработки программного обеспечения.

Вам предстоит путешествие, по завершении которого вы станете новоиспеченным, но профессиональным программистом на C.

Объекты, функции и типы


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

Объекты, функции, типы и указатели

Объект — это хранилище, в котором можно представлять значения. Если быть точным, то в стандарте C (ISO/IEC 9899:2018) объектом называется «область хранилища данных в среде выполнения, содержимое которого может представлять значения» с примечанием: «при обращении к объекту можно считать, что он обладает определенным типом». Один из примеров объекта — переменная.

Переменные имеют объявленный тип, который говорит о том, какого рода объект представляет его значение. Например, объект типа int содержит целочисленное значение. Важность типа объясняется тем, что набор битов, представляющий объект одного типа, скорее всего, будет иметь другое значение, если его интерпретировать как объект другого типа. Например, в IEEE 754 (стандарт IEEE для арифметических операций с плавающей запятой) число 1 представлено как 0x3f800000 (IEEE 754–2008). Но если интерпретировать тот же набор битов как целое число, то вместо 1 получится значение 1 065 353 216.

Функции не являются объектами, но тоже имеют тип. Тип функции характеризуется как ее возвращаемым значением, так и числом и типами ее параметров.

Кроме того, в C есть указатели, которые можно считать адресами — областями памяти, в которых хранятся объекты или функции. Тип указателя, основанный на типе функции или объекта, называется ссылочным типом. Указатель, имеющий ссылочный тип T, называют указателем на T.

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

Объявление переменных

При объявлении переменной вы назначаете ей тип и даете ей имя — идентификатор, по которому к ней можно обращаться.

В листинге 2.1 объявляются два целочисленных объекта с исходными значениями. Эта простая программа также объявляет, но не определяет функцию swap, которая впоследствии будет менять эти значения местами.

Листинг 2.1. Программа, которая должна менять местами два целых числа

#include <stdio.h>void swap(int, int); // определена в листинге 2.2
int main(void) {
   int a = 21;
   int b = 17;
swap(a, b);
   printf("main: a = %d, b = %d\n", a, b);
   return 0;
}

Эта демонстрационная программа состоит из функции main с единственным блоком кода между фигурными скобками. Такого рода блоки называют составными операторами. Внутри функции main мы определяем две переменные, a и b. Мы объявляем их как переменные типа int и присваиваем им значения 21 и 17 соответственно. У всякой переменной должно быть объявление. Затем внутри main происходит вызов функции swap, чтобы поменять местами значения этих двух целочисленных переменных. В данной программе функция swap объявлена, но не определена. Позже в этом разделе мы рассмотрим некоторые потенциальные ее реализации.

Объявление нескольких переменных
Вы можете объявлять сразу несколько переменных, но это может сделать код запутанным, если они имеют разные типы или являются указателями или массивами. Например, все следующие объявления являются корректными:

char *src, c;
int x, y[5];
int m[12], n[15][3], o[21];

В первой строчке объявляются две переменные, src и c, которые имеют разные типы. Переменная src имеет тип char *, а c — тип char. Во второй строчке тоже происходит объявление двух переменных разных типов, x и y; первая имеет тип int, а вторая является массивом из пяти элементов типа int. В третьей строчке объявлено три массива, m, n и o, с разной размерностью и количеством элементов.

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

char *src; // src имеет тип char *
char c; // c имеет тип char
int x; // x имеет тип int
int y[5]; // y — это массив из 5 элементов типа int
int m[12]; // m — это массив из 12 элементов типа int
int n[15][3]; // n — это массив из 15 массивов, состоящих из трех
// элементов типа int
int o[21]; // o — это массив из 21 элемента типа int

В удобочитаемом и понятном коде реже встречаются ошибки.

Перестановка значений местами (первая попытка)

У каждого объекта есть срок хранения, который определяет его время жизни (lifetime) — период выполнения программы, на протяжении которого этот объект существует, где-то хранится, имеет постоянный адрес и сохраняет последнее присвоенное ему значение. К объектам нельзя обращаться вне данного периода.

Локальные переменные, такие как a и b из листинга 2.1, имеют автоматический срок хранения (storage duration); то есть они существуют, пока поток выполнения не покинет блок, в котором они определены. Попробуем поменять местами значения, хранящиеся в этих двух переменных.

Листинг 2.2 отображает нашу первую попытку реализовать функцию swap.

Листинг 2.2. Функция swap

void swap(int a, int b) {
   int t = a;
   a = b;
   b = t;
   printf("swap: a = %d, b = %d\n", a, b);
}

Функция swap объявляет два параметра, a и b, с помощью которых вы передаете ей аргументы. В C есть разница между параметрами и аргументами. Первые — это объекты, которые объявляются вместе с функцией и получают значения при входе в нее, а вторые — это выражения, разделяемые запятыми, которые указываются в выражении вызова функции. Мы также объявляем в функции swap временную переменную t типа int и инициализируем ее с помощью значения a. Данная переменная используется для временного хранения значения a, чтобы оно не было утеряно во время перестановки.

Теперь мы можем скомпилировать и проверить нашу полноценную программу, запустив сгенерированный исполняемый файл:

% ./a.out
swap: a = 17, b = 21
main: a = 21, b = 17

Результат может вас удивить. Изначально переменные a и b равны 21 и 17 соответственно. Первый вызов printf внутри функции swap показывает, что эти два значения поменялись местами, однако, если верить второму вызову printf в main, исходные значения остались неизменными. Посмотрим, что же произошло.

В языке C передача аргументов при вызове происходит по значению; то есть когда вы предоставляете функции аргумент, его значение копируется в отдельную переменную, доступную для использования внутри этой функции. Функция swap присваивает значения объектов, переданных в виде аргументов, соответствующим параметрам. Изменение значений параметров в функции не влияет на значения в вызывающем коде, поскольку это разные объекты. Следовательно, переменные a и b сохраняют исходные значения в main во время второго вызова printf. Программа должна была поменять местами значения этих двух объектов. Протестировав ее, мы обнаружили в ней ошибку (или дефект).

Перестановка значений местами (вторая попытка)

Чтобы исправить эту ошибку, мы можем переписать функцию swap с помощью указателей. Применим операцию косвенного обращения (или разыменовывания) *, чтобы объявить и разыменовать указатели, как показано в листинге 2.3.

Листинг 2.3. Переработанная функция swap с использованием указателей

void swap(int *pa, int *pb) {
   int t = *pa;
   *pa = *pb;
   *pb = t;
   return;
}

В объявлении или определении функции операция * выступает частью объявления указателя, сигнализируя о том, что параметр является указателем на объект или функцию заданного типа. В переписанной функции swap указано два параметра, pa и pb, объявленных как указатели типа int.

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

pa = pb;

Здесь значение указателя pa заменяется значением указателя pb. Теперь посмотрите, как происходит присваивание в функции swap:

*pa = *pb;

Это выражение разыменовывает указатель pb, считывает значение, на которое тот ссылается, разыменовывает указатель pa и затем вместо значения по адресу, на который ссылается pa, записывает значение, на которое ссылается pb.

При вызове функции swap в main необходимо также указать амперсанд (&) перед именем каждой переменной:

swap(&a, &b);

Унарная операция & используется для взятия адреса. Она генерирует указатель на свой операнд. Это изменение необходимо, поскольку функция swap теперь принимает в качестве параметров указатели на объекты типа int, а не просто значения данного типа.

В листинге 2.4 показана вся программа swap целиком, с описанием объектов, создаваемых во время ее работы, и их значений.

Листинг 2.4. Имитация передачи аргументов по ссылке

#include <stdio.h>
void swap(int *pa, int *pb) { // pa → a: 21 pb → b: 17
   int t = *pa; // t: 21
   *pa = *pb; // pa → a: 17 pb → b: 17
   *pb = t; // pa → a: 17 pb → b: 21
}
int main(void) {
   int a = 21; // a: 21
   int b = 17; // b: 17
   swap(&a, &b);
   printf("a = %d, b = %d\n", a, b); // a: 17 b: 21
   return 0;
}

Во время входа в блок main переменным a и b присваиваются значения 21 и 17 соответственно. Затем код берет адреса этих объектов и передает их функции swap в качестве аргументов.

Параметры pa и pb внутри swap объявлены в виде указателей типа int и содержат копии аргументов, переданных этой функции из вызывающего кода (в данном случае main). Эти копии адресов по-прежнему ссылаются на те же объекты, поэтому, когда функция swap меняет эти объекты местами, содержимое исходных объектов, объявленных в main, тоже меняется. Данный подход имитирует передачу аргументов по ссылке: сначала генерируются адреса объектов, которые передаются по значению, а затем они разыменовываются для доступа к исходным объектам.

Область видимости

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

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

Если объявление происходит внутри блока или списка параметров, то оно имеет блочную область видимости; то есть объявленный им идентификатор доступен только внутри этого блока. Примерами этого выступают идентификаторы a и b из листинга 2.4; они позволяют ссылаться на эти переменные только внутри блока кода в функции main, в котором они объявлены.

Если объявление происходит внутри списка параметров прототипа функции (но не в ее теле), то область видимости будет ограничена ее объявлением (то есть прототипом). Область видимости функции охватывает ее определение между открывающей ({) и закрывающей (}) скобками. Единственным видом идентификаторов, который имеет такую область видимости, являются метки — идентификаторы, завершающиеся двоеточием и определяющие оператор в функции, к которому может перейти управление. О метках и передаче управления речь пойдет в главе 5.

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

Если объявить один и тот же идентификатор как во внутренней, так и в наружной областях видимости, то внутренняя версия перекрывает наружную, имея более высокий приоритет. В данном случае имя идентификатора будет ссылаться на объект во внутренней области видимости; объект из наружной области скрывается, и на него нельзя сослаться по имени. Чтобы это не создавало проблем, лучше использовать разные имена.

В листинге 2.5 показаны разные области видимости и то, как идентификаторы, объявленные во внутренних областях, могут перекрывать идентификаторы, объявленные в наружных.

Листинг 2.5. Области видимости

int j; // начинается область видимости уровня файла j
void f(int i) { // начинается блочная область видимости i
   int j = 1; // начинается блочная область видимости j // перекрывает j в области видимости уровня файла
   i++; // i ссылается на параметр функции
   for (int i = 0; i < 2; i++) { // начинается блочная область видимости i // внутри цикла
      int j = 2; // начинается блочная область видимости j // перекрывает внешнюю j
      printf("%d\n", j); // внутренняя j в области видимости, выводит 2
   } // заканчивается блочная область видимости // внутренних i и j
   printf("%d\n", j); // внешняя j в области видимости, выводит 1
\} // заканчивается блочная область видимости i и j
void g(int j); // j имеет область видимости уровня прототипа // перекрывает j уровня файла

С этим кодом все в порядке, если предположить, что комментарии правильно передают замысел автора. Чтобы избежать путаницы и потенциальных ошибок, для разных идентификаторов лучше использовать разные имена. Применение коротких имен наподобие i и j подходит для идентификаторов с маленькими областями видимости. Если область видимости большая, то лучше прибегнуть к наглядным именам, которые вряд ли будут перекрыты во вложенных областях. Некоторые компиляторы предупреждают о перекрытых идентификаторах.

Срок хранения

Объекты имеют срок хранения, который определяет их время жизни. В целом сроки хранения бывают четырех видов: автоматические, статические, потоковые и выделенные. Вы уже видели, что объекты с автоматическим сроком хранения объявляются внутри блока или в качестве параметров функции. Их время жизни начинается в момент выполнения блока, в котором они объявлены, и заканчивается при его завершении. Если блок вызывается рекурсивно, то при каждом вызове создается новый объект с собственным сроком хранения.

Область видимости и время жизни — совершенно разные понятия. Первое относится к идентификаторам, а второе — к объектам. Областью видимости идентификатора является участок кода, в котором к объекту, обозначенному этим идентификатором, можно получить доступ по его имени. Время жизни объекта — это период, на протяжении которого он существует.


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

Листинг 2.6. Пример счетчика

void increment(void) {
   static unsigned int counter = 0;
   counter++;
   printf("%d ", counter);
}
int main(void) {
   for (int i = 0; i < 5; i++) {
      increment();
   }
   return 0;
}

Эта программа выводит 1 2 3 4 5. Мы присваиваем статической переменной counter значение 0 во время запуска программы и затем инкрементируем его при каждом вызове функции increment. Время жизни counter охватывает весь период выполнения программы, а последнее значение данной переменной будет храниться, пока она существует. То же поведение можно получить, объявив counter на уровне файла. Однако при разработке ПО рекомендуется как можно сильнее ограничивать область видимости объектов.

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

int *func(int i) {
   const int j = i; // правильно
   static int k = j; // ошибка
   return &k;
}

К константным значениям относятся константы-литералы (например, 1, 'a' или 0xFF), члены enum и результаты работы операций, таких как alignof или sizeof, но не объекты со спецификатором const.

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

Более подробно с книгой можно ознакомиться на сайте издательства
» Оглавление
» Отрывок

Для Хаброжителей скидка 25% по купону — Си

По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
Tags:
Hubs:
Total votes 9: ↑7 and ↓2+7
Comments14

Articles

Information

Website
piter.com
Registered
Founded
Employees
201–500 employees
Location
Россия