автордың кітабын онлайн тегін оқу Экстремальный Cи. Параллелизм, ООП и продвинутые возможности
Переводчики О. Сивченко, С. Черников
Литературный редактор Н. Хлебина
Художник В. Мостипан
Корректоры О. Андриевич, Е. Павлович
Камран Амини
Экстремальный Cи. Параллелизм, ООП и продвинутые возможности. — СПб.: Питер, 2021.
ISBN 978-5-4461-1694-2
© ООО Издательство "Питер", 2021
Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав.
Об авторе
Камран Амини специализируется на ядре и встроенных системах. Он работал инженером, архитектором, консультантом и техническим директором во множестве известных иранских компаний. В 2017 году переехал в Европу, где трудился старшим архитектором и инженером в таких солидных компаниях, как Jeppesen, Adecco, TomTom и ActiveVideo Networks. Во время своего пребывания в Амстердаме Камран и написал эту книгу. Его больше всего интересуют следующие темы: теория алгоритмов, распределенные системы, машинное обучение, теория информации и квантовые вычисления.
Хочу поблагодарить маму Эхтирам, которая посвятила жизнь воспитанию меня и моего брата Ашкана. Она всегда нас поддерживает.
Хочу также поблагодарить мою прекрасную и любимую жену Афсанех, которая поддерживала меня на каждом этапе, особенно во время работы над этой книгой. Без ее терпения и поддержки я бы никогда не справился.
О научных редакторах
Алиакбар Аббаси — разработчик ПО с более чем восьмилетним опытом использования различных технологий и языков программирования. Специалист в ООП, C/C++ и Python. Любит читать техническую литературу и расширять свой кругозор в области программирования. В настоящее время проживает в Амстердаме и работает старшим программистом в компании TomTom.
Рохит Талвалкар — очень опытный специалист в программировании на языках C, C++ и Java. На его счету разработка приложений, драйверов и сервисов для проприетарной версии RTOS (Real Time OS), Windows, устройств на основе Windows Mobile и платформы Android.
Рохит получил диплом бакалавра технических наук в индийском Технологическом институте города Мумбаи, а также диплом магистра СS. В настоящее время занимает должность ведущего разработчика приложений в сфере смешанной реальности. Рохит успел поработать в Motorola и BlackBerry и сейчас является сотрудником компании Magic Leap, которая выпускает очки смешанной реальности и специализируется на пространственных вычислениях. В свое время редактировал книгу C++ for the Impatient Брайана Оверленда.
Хочу поблагодарить доктора Кловиса Тондо, который научил меня C, C++, Java и многому другому.
Введение
В современном мире мы регулярно имеем дело с умопомрачительными технологиями, которые невозможно было представить еще несколько десятилетий назад. На улицах начинают появляться беспилотные автомобили. Достижения в физике и других науках меняют наши представления о реальности как таковой. Мы читаем новости о том, как исследователи делают первые шаги в квантовых вычислениях, о блокчейне и криптовалютах, о планах колонизации других планет. Невероятно, но в основе таких разнообразных по своей природе достижений лежат всего несколько технологий, одной из которых посвящена эта книга. Речь идет о языке C.
Я начал программировать на C++ в девятом классе, присоединившись к команде юных разработчиков, занимавшейся созданием 2D-симулятора игры в футбол. Вскоре после C++ я начал изучать Linux и C. Стоит признать, что в те времена важность C и Unix не была для меня столь очевидной, но, постепенно получая опыт использования этих технологий в разных проектах и все больше узнавая о них, я осознал, насколько важную роль они играют. Чем ближе я знакомился с C, тем сильнее уважал этот язык программирования. В конце концов я решил стать профессиональным программистом на C. Мне хотелось делиться своими знаниями с другими людьми, чтобы они тоже понимали всю важность этой технологии. Данная книга стала результатом моих амбиций.
Бытует заблуждение, будто C — мертвый язык, и в целом многие технические специалисты имеют о нем туманное представление. Чтобы убедиться в обратном, достаточно взглянуть на рейтинг TIOBE по адресу www.tiobe.com/tiobe-index. На самом деле C наряду с Java — один из самых известных языков за прошедшие 15 лет, и в последние годы он только набирал популярность.
Я подошел к написанию этой книги, имея многолетний опыт в разработке и проектировании с помощью C, C++, Golang, Java и Python на разных платформах, включая различные версии BSD Unix, Linux и Microsoft Windows. Моей основной целью было вывести навыки читателей на новый уровень, поделиться с ними опытом, полученным тяжелым трудом. Легкой прогулки ждать не стоит, именно поэтому книга называется «Экстремальный Cи. Параллелизм, ООП и продвинутые возможности». Мы не станем отвлекаться и сравнивать C с другими языками программирования. Я попытался сделать текст максимально практичным, однако он все равно содержит большое количество фундаментального теоретического материала, имеющего реальное применение. Множество примеров, представленных здесь, помогут вам подготовиться к тому, с чем вам предстоит столкнуться в реальных проектах.
Возможность взяться за столь весомую тему — большая честь для меня. Это сложно выразить словами, и потому скажу лишь, что мне было очень приятно писать о том, что так близко моему сердцу. Это моя первая книга, и возможностью быть ее автором я обязан Эндрю Валдрону.
Заодно хочу передать привет и поблагодарить Йена Хью, редактора-консультанта, с которым мы бок о бок трудились над каждой главой, Алиакбара Аббаси за его замечания и предложения, а также Кишора Рита, Гаурава Гаваса, Веронику Пэйс и многих других людей, внесших ценный вклад в подготовку и издание этой книги.
Я предлагаю вам стать моими спутниками в этом длинном путешествии. Надеюсь, чтение книги изменит ваш кругозор, поможет вам увидеть C в новом свете и заодно сделает вас отличным программистом.
Для кого эта книга
Книга предназначена для читателей, уже имеющих минимальный уровень знаний в области разработки на C и C++. Основная аудитория — начинающие и middle-программисты на C/C++; именно они смогут извлечь максимальную пользу из прочитанного материала, применяя полученные навыки и знания. Надеюсь, книга поможет им ускорить карьерный рост и стать старшими разработчиками. Кроме того, прочитав ее, они смогут претендовать на большое количество вакансий с высокими требованиями и, как правило, хорошей зарплатой. Те или иные темы могут пригодиться и опытным программистам на C/C++, хотя я ожидаю, что эти люди в целом знакомы с изложенным здесь материалом и могут почерпнуть для себя лишь некоторые полезные детали.
Еще одна категория читателей, которым может пригодиться эта книга, — студенты и научные сотрудники. Возможно, вы получаете высшее образование или учитесь в аспирантуре в любой научной или технической области, такой как информатика, разработка ПО, искусственный интеллект, Интернет вещей (Internet of Things, IoT), астрономия, физика частиц и космология, либо занимаетесь исследованиями в этих сферах. Книга позволит повысить уровень ваших знаний о C/C++ и Unix-подобных операционных системах, а также поможет отточить соответствующие навыки программирования. Представленный материал пригодится инженерам и ученым, работающим над сложными, многопоточными или даже многопроцессными системами для удаленного управления устройствами, моделирования, обработки больших объемов данных, машинного/глубокого обучения и т.д.
Структура издания
Книга состоит из семи условных частей, каждая из которых посвящена определенным аспектам программирования на C. В первой части рассматривается создание проекта на C, во второй обсуждается память, в третьей — объектная ориентированность, а в четвертой речь в основном идет о системах Unix и их связях с языком C. В пятой части мы поговорим о конкурентности, в шестой — о межпроцессном взаимодействии, а в седьмой, заключительной части речь пойдет о тестировании и сопровождении кода. Ниже приводится краткое описание каждой из 23 глав книги.
Глава 1 «Основные возможности языка» посвящена основным возможностям C, которые определяют то, как мы используем данный язык. В число главных тем входят определение макросов с помощью препроцессора и директив, указатели на переменные и функции, механизмы вызова функций, а также структуры.
Глава 2 «Компиляция и компоновка» содержит описание сборки проектов на C. Во всех подробностях будут рассмотрены как процесс компиляции в целом, так и отдельные его элементы.
Глава 3 «Объектные файлы» посвящена результатам компиляции проекта на C. Вы познакомитесь с объектными файлами, заглянете внутрь этих файлов и посмотрите, какую информацию из них можно извлечь.
Глава 4 «Структура памяти процесса» исследует внутреннее устройство памяти процесса. Вы узнаете, из каких сегментов состоит память и чем статическая память отличается от динамической.
Глава 5 «Стек и куча» содержит информацию о таких сегментах, как стек и куча. Мы поговорим о переменных, которые в них хранятся, и об управлении их жизненным циклом в C. Вы научитесь передовым практикам работы с кучей.
Глава 6 «ООП и инкапсуляция» — первая из четырех глав, относящихся к объектной ориентированности в C. Мы пройдемся по теории, стоящей за ООП, и будет дано определение важным терминам, которые часто встречаются в технической литературе.
Глава 7 «Композиция и агрегация» посвящена композиции и ее специальной разновидности — агрегации. Мы обсудим их отличия, которые проиллюстрируем с помощью примеров.
Глава 8 «Наследование и полиморфизм» исследует один из самых важных аспектов объектно-ориентированного программирования (ООП) — наследование. Я покажу, как происходит наследование между двумя классами и как это можно реализовать в C. Вдобавок мы поговорим еще об одной обширной теме — полиморфизме.
Глава 9 «Абстракция данных и ООП в C++» является заключительной для третьей части книги и отведена теме абстракции. Вы познакомитесь с абстрактными типами данных и узнаете, как они реализованы в C. Мы обсудим C++ и рассмотрим объектно-ориентированные концепции на примере этого языка.
При обсуждении языка C нельзя не упомянуть о Unix. Глава 10 «История и архитектура Unix» объясняет, почему эти две технологии так тесно связаны между собой и как данный симбиоз способствует их живучести. Мы также рассмотрим архитектуру операционной системы Unix и узнаем, как программы используют предоставляемые ею возможности.
Глава 11 «Системные вызовы и ядра» посвящена пространству ядра в архитектуре Unix. Мы подробно обсудим системные вызовы и рассмотрим, как их можно создавать в Linux. Вдобавок поговорим о разных видах ядер и увидим принцип работы модулей ядра Linux на примере простого модуля.
Глава 12 «Последние нововведения в C» представляет последнюю версию стандарта C под названием C18. Вы увидите, чем она отличается от предыдущей версии, C11. Я также продемонстрирую ряд новых возможностей, которые появились с момента выхода C99.
Глава 13 «Конкурентность» открывает пятую часть и посвящена конкурентности. Мы в основном обсудим среды конкурентного выполнения и их различные свойства, такие как чередование. Я объясню, почему эти системы недетерминистические и каким образом данная особенность может вызывать проблемы конкурентности, такие как состояние гонки.
Глава 14 «Синхронизация» побуждает продолжить наше обсуждение сред конкурентного выполнения и обращает внимание на разные проблемы, которые в них встречаются, включая состояние гонки, конкуренцию за данные и взаимную блокировку. Вы познакомитесь с методиками, позволяющими преодолеть эти проблемы, такими как семафоры, мьютексы и условные переменные.
Глава 15 «Многопоточное выполнение» демонстрирует одновременное выполнение нескольких потоков и способы управления ими. Я также приведу реальные примеры с проблемами конкурентности в C, перечисленные в предыдущей главе.
Глава 16 «Синхронизация потоков» описывает методы синхронизации нескольких потоков. Среди представленных здесь тем можно выделить семафоры, мьютексы и условные переменные.
Глава 17 «Процессы» представляет способы создания или порождения новых процессов. Мы также обсудим пассивные и активные методики разделения состояния между разными процессами и рассмотрим проблемы с конкурентностью, затронутые в главе 14, используя реальные примеры на языке C.
Глава 18 «Синхронизация процессов» в основном посвящена имеющимся механизмам для синхронизации разных процессов, находящихся на одном и том же компьютере, включая межпроцессные семафоры, мьютексы и условные переменные.
Глава 19 «Локальные сокеты и IPC» основное внимание уделяет пассивным методам межпроцессного взаимодействия (interprocess communication, IPC). Основной акцент делается на взаимодействии процессов, находящихся на одном компьютере. Вы также познакомитесь с программированием сокетов и научитесь создавать каналы между процессами, принадлежащими разным сетевым узлам.
Глава 20 «Программирование сокетов» представляет сетевое программирование и содержит примеры кода, которые проиллюстрируют разные типы сокетов, включая сокеты домена Unix, а также TCP- и UDP-сокеты, основанные на поточных и датаграммных каналах.
Глава 21 «Интеграция с другими языками» демонстрирует, как библиотеку на C, собранную в виде динамического объектного файла, можно загружать и использовать в программах, написанных на C++, Java, Python и Golang.
Глава 22 «Модульное тестирование и отладка» посвящена тестам разных видов, но мы сосредоточимся на модульном тестировании в C. Вы познакомитесь с библиотеками CMocka и Google Test, предназначенными для написания наборов тестов в C. Применительно к отладке мы пройдемся по различным инструментам, которые позволяют отлаживать разного рода программные ошибки.
Глава 23 «Системы сборки», заключительная, представляет системы сборки, такие как Make, Ninja и Bazel, и один генератор сборочных скриптов, CMake.
Условия, при соблюдении которых книга будет максимально полезной
Как уже объяснялось, от читателя требуется определенный уровень знаний и навыков в области программирования.
• Общее понимание архитектуры компьютера. Вы должны иметь представление о памяти, центральном процессоре, периферийных устройствах и их характеристиках и понимать, как компьютерные программы взаимодействуют с этими элементами.
• Владение основами программирования. Вы должны знать, что такое алгоритм, как проследить за его выполнением, что собой представляет исходный код и как в двоичной системе выполняются арифметические операции.
• Навыки работы с терминалом и основными утилитами командной строки в Unix-подобных операционных системах, таких как Linux или macOS.
• Понимание таких тем, как условные выражения, разные виды циклов, структуры или классы минимум в одном языке программирования, указатели в C или C++, функции и т.д.
• Знание основ ООП. Требование необязательное, поскольку ООП подробно рассматривается в книге, однако знание этой темы поможет вам лучше понять материал, изложенный в главах, посвященных объектной ориентированности.
Кроме того, настоятельно рекомендуется скачать репозиторий с кодом и самостоятельно выполнять команды, которые приводятся в листингах командной оболочки. Пожалуйста, используйте компьютер под управлением Linux, macOS или другой операционной системы, совместимой с POSIX.
Скачивание файлов с примерами кода
Вы можете скачать файлы с кодом, выполнив следующие шаги.
1. Войдите или зарегистрируйтесь на сайте http://www.packt.com.
2. Выберите вкладку Support (Поддержка).
3. Щелкните на ссылке Code Downloads (Загрузка кода).
4. Введите английское название книги (Extreme C) в поле Search (Искать) и следуйте инструкциям, которые появятся на экране.
Скачав файл, не забудьте его распаковать, используя последнюю версию одного из следующих инструментов:
• WinRAR/7-Zip для Windows;
• Zipeg/iZip/UnRarX для Mac;
• 7-Zip/PeaZip для Linux.
Архив с кодом для этой книги также доступен на GitHub по адресу github.com/PacktPublishing/Extreme-C. Все обновления кода вносятся в существующий GitHub-репозиторий.
У нас есть богатая библиотека книг и видеороликов. Примеры кода для них можно найти на сайте github.com/PacktPublishing/.
Условные обозначения
В книге используются листинги кода и командной оболочки. Первые содержат либо код на языке C, либо псевдокод. Пример листинга кода показан ниже (листинг 17.1).
Листинг 17.1. Создание дочернего процесса с помощью API fork (ExtremeC_examples_chapter17_1.c)
#include <stdio.h>
#include <unistd.h>
int main(int argc, char** argv) {
printf("This is the parent process with process ID: %d\n",
getpid());
printf("Before calling fork() ...\n");
pid_t ret = fork();
if (ret) {
printf("The child process is spawned with PID: %d\n", ret);
} else {
printf("This is the child process with PID: %d\n", getpid());
}
printf("Type CTRL+C to exit ...\n");
while (1);
return 0;
}
Как видите, приведенный выше код можно найти в файле ExtremeC_examples_chapter17_1.c, который находится в архиве с примерами для этой книги, в каталоге главы 17. Архив с кодом доступен по ссылке github.com/PacktPublishing/Extreme-C.
Если листинг не связан ни с каким файлом, он содержит псевдокод или код на языке C, который не вошел в архив. Вот пример.
Листинг 13.1. Простое задание с пятью инструкциями
Task P {
1. num = 5
2. num++
3. num = num – 2
4. x = 10
5. num = num + x
}
Иногда некоторые строки в листингах выделены жирным шрифтом. Они обычно обсуждаются перед листингом или после него, а выделяются для того, чтобы вам было легче их найти.
Листинги командной оболочки используются для иллюстрации вывода консольных команд, запускаемых в терминале. Сами команды, как правило, напечатаны жирным шрифтом, а их вывод — обычным. Вот пример.
Терминал 17.6. Чтение из объекта разделяемой памяти, созданного в примере 17.4, и его удаление
$ ls /dev/shm
shm0
$ gcc ExtremeC_examples_chapter17_5.c -lrt -o ex17_5.out
$ ./ex17_5.out
Shared memory is opened with fd: 3
The contents of the shared memory object: ABC
$ ls /dev/shm
$
Команды начинаются либо с $, либо с #. В первом случае команду следует выполнять от имени обычного пользователя, а во втором — от имени администратора.
Консольные команды обычно выполняют в каталоге соответствующей главы, который находится в архиве с кодом. Если нужно перейти в определенный рабочий каталог, то я вам об этом сообщу.
Курсивом выделены новые термины или слова, на которых нужно акцентировать внимание. Шрифт без засечек используется для ссылок и названий каталогов, а также для элементов интерфейса. Например: «Выберите раздел System info (Системная информация) на панели Administration (Администрирование)». Названия файлов оформляются моноширинным шрифтом.
Предупреждения и важные замечания оформлены так.
Советы и приемы оформлены таким образом.
От издательства
Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com (издательство «Питер», компьютерная редакция).
Мы будем рады узнать ваше мнение!
На сайте издательства www.piter.com вы найдете подробную информацию о наших книгах.
1. Основные возможности языка
Эта книга поможет вам получить как базовые, так и углубленные знания, необходимые для разработки и сопровождения реальных приложений на C. Как правило, для написания успешных программ одного лишь синтаксиса языка программирования недостаточно — и это особенно актуально для C, в сравнении с большинством других языков. И потому мы рассмотрим все концепции, с помощью которых вы сможете создавать замечательное ПО, — от простых утилит до сложных многопроцессорных систем.
Глава 1 в основном посвящена конкретным возможностям C, которые, как вы сами увидите, будут чрезвычайно полезными при написании программ. Вы будете применять их в ситуациях, регулярно встречающихся в разработке ПО. О программировании на C написано множество замечательных книг и практических руководств, подробно освещающих почти все аспекты синтаксиса этого языка, но, прежде чем идти дальше, будет полезно обсудить некоторые из его ключевых особенностей.
В число этих особенностей входят директивы препроцессора, указатели на переменные, функции и структуры. Конечно, все это можно встретить и в более современных языках программирования, а аналогичные концепции доступны в Java, C#, Python и т.д. Например, ссылки в Java можно считать аналогом указателей на переменные в C. Эти возможности и связанные с ними концепции настолько фундаментальны, что ни один программный компонент не смог бы работать без них, даже если бы его можно было запустить! Даже простейшая программа типа Hello world нуждается в загрузке целого ряда динамических библиотек, что, в свою очередь, требует использования указателей на функции!
Светофор, компьютерная система вашего автомобиля, микроволновая печь на вашей кухне, операционная система вашего смартфона или, наверное, любого другого устройства, о котором вы обычно даже не задумываетесь, — все это содержит программные компоненты, написанные на языке C.
Появление C оказало огромное влияние на нашу жизнь, и без него наш мир выглядел бы совсем иначе.
Эта глава посвящена основным возможностям и механизмам, необходимым для написания кода на C. Будут подробно рассмотрены следующие элементы языка.
• Директивы препроцессора, макросы и условная компиляция. Наличие препроцессора — одна из особенностей C, которая не характерна для большинства других языков программирования. Препроцессор дает множество преимуществ, и мы обсудим некоторые интересные сценарии его применения, включая макросы и условные директивы.
• Указатели на переменные рассматриваются в соответствующем разделе. Чтобы лучше их понять, мы также обсудим примеры их неправильного использования.
• Функции. В соответствующем разделе подробно описывается все, что с ними связано. Мы не станем ограничиваться одним лишь синтаксисом. На самом деле синтаксис — самое простое! Мы будем рассматривать функции в качестве составных элементов для написания процедурного кода. Мы также обсудим механизм вызова функций и то, как они получают свои аргументы от вызывающего их кода.
• Указатели на функции. Это, несомненно, один из важнейших аспектов C. Указатели могут ссылаться не только на переменные, но и на функции. Возможность сохранять указатели на существующую логику крайне важна для разработки алгоритмов, и именно поэтому данной теме посвящен отдельный раздел. Указатели на функции имеют широкий спектр применения, от загрузки динамических библиотек до полиморфизма. Они будут повсеместно встречаться в следующих нескольких главах.
• Структуры в C имеют простой синтаксис и выражают простую идею, но это основные составляющие элементы для написания хорошо организованного и более объектно-ориентированного кода. Их важность в сочетании с указателями на функции невозможно переоценить! В последнем разделе данной главы мы еще раз пройдемся по всем аспектам структур в C, о которых вам нужно знать, и рассмотрим присущие им нюансы.
Основные возможности C и сопутствующие концепции играют ключевую роль в экосистеме Unix, благодаря чему этот язык, несмотря на почтенный возраст и строгий синтаксис, является важной и влиятельной технологией. О том, как C и Unix связаны между собой, мы поговорим в следующих главах. А пока сосредоточимся на директивах препроцессора.
Прежде чем продолжим, имейте в виду: вы уже должны быть знакомы с C. В текущей главе в основном представлены обычные примеры, но без знания синтаксиса языка вы не сможете двигаться дальше. Ниже перечислены темы, в которых необходимо ориентироваться любому читателю данной книги.
• Общее понимание архитектуры компьютера. Вы должны иметь представление о памяти, центральном процессоре, периферийных устройствах и их характеристиках и понимать, как компьютерные программы взаимодействуют с этими элементами.
• Знание основ программирования. Вы должны знать, что такое алгоритм, как проследить за его выполнением, что собой представляет исходный код и как работают арифметические операции в двоичной системе.
• Навыки работы с терминалом и основными утилитами командной строки в Unix-подобных операционных системах, таких как Linux или macOS.
• Владение такими темами, как условные выражения, разные виды циклов, структуры или классы минимум в одном языке программирования, указатели в C или C++, функции и т.д.
• Понимание основ ООП. Требование необязательное, поскольку ООП подробно рассматривается в этой книге, однако знание данной темы поможет вам лучше понять материал, изложенный в главах третьей части, посвященной объектной ориентированности.
Директивы препроцессора
Препроцессор — важная часть C. Мы подробно рассмотрим его в главе 2, но пока будем считать, что это механизм, который позволяет вам генерировать и модифицировать свой исходный код до его передачи компилятору. Это значит, процесс компиляции в C имеет по меньшей мере один дополнительный этап по сравнению с другими языками программирования. В них исходный код сразу попадает в компилятор, но в C и C++ он должен сначала пройти через препроцессор.
Этот дополнительный этап делает C и (C++) уникальным языком программирования, поскольку программист может фактически изменять свой исходный код перед передачей его компилятору. В большинстве высокоуровневых языков программирования такой возможности нет.
Задача препроцессора — заменить специальные директивы подходящим кодом на C и подготовить итоговые исходники к компиляции.
Управлять препроцессором в C и влиять на его поведение можно с помощью набора директив. Они представляют собой строчки кода, начинающиеся символом # как в заголовочных, так и в исходных файлах. Эти строчки имеют смысл только для препроцессора, но не для компилятора. C поддерживает различные директивы, но часть из них играют ключевую роль, особенно те, которые используются в определении макросов и условной компиляции.
В следующем подразделе я объясню, что такое макросы, и приведу различные примеры их использования. Кроме того, мы проанализируем их достоинства и недостатки.
Макросы
Макросы в C окружены ореолом таинственности. Одни говорят, что они делают исходный код слишком сложным и малопонятным, а другие уверены, что из-за них возникают проблемы при отладке приложений. Возможно, вы и сами встречали подобные слухи. Но правдивы ли они и если да, то в какой степени? Являются ли макросы злом, которого следует избегать? Или же они имеют определенные преимущества, которые могли бы пригодиться в вашем проекте?
В действительности макросы можно найти в любом известном проекте на C. Чтобы в этом убедиться, скачайте какое-нибудь популярное приложение наподобие HTTP-сервера Apache и поищите в его исходниках #define с помощью утилиты grep. Вы найдете длинный список файлов, в которых определен данный макрос. Макросы — неотъемлемая часть жизни разработчика на C. Даже если вы не используете их сами, они, скорее всего, попадутся вам в чужом коде. Поэтому вы должны знать, что они собой представляют и как с ними работать.
Утилита grep — стандартная утилита командной строки в Unix-подобных операционных системах, предназначенная для поиска шаблонных выражений в потоках символов. С ее помощью можно искать текст и шаблоны в содержимом всех файлов в заданном каталоге.
Макросы можно применять различными способами. Ниже перечислено несколько примеров:
• определение константы;
• использование вместо обычной функции на C;
• разворачивание цикла;
• предотвращение дублирования заголовков;
• генерация кода;
• условная компиляция.
Макросы применяются во множестве других ситуаций, однако ниже мы сосредоточимся на тех из них, которые перечислены выше.
Определение макроса
Для определения макросов используется директива #define. Каждый макрос имеет имя и (иногда) список параметров. У него также есть значение, которое подставляется вместо его имени на этапе работы препроцессора под названием «развертывание макросов». С помощью директивы #undef макрос можно сделать неопределенным. Начнем с простого примера (листинг 1.1).
Листинг 1.1. Определение макроса (ExtremeC_examples_chapter1_1.c)
#define ABC 5
int main(int argc, char** argv) {
int x = 2;
int y = ABC;
int z = x + y;
return 0;
}
В приведенном выше листинге ABC — не переменная с целочисленным значением и не целочисленная константа. На самом деле это макрос с именем ABC, значение которого равно 5. После его развертывания итоговый код, который передается компилятору, выглядит примерно так (листинг 1.2).
Листинг 1.2. Код, сгенерированный в результате развертывания макроса из примера 1.1
int main(int argc, char** argv) {
int x = 2;
int y = 5;
int z = x + y;
return 0;
}
Код в листинге 1.2 имеет корректный с точки зрения C синтаксис, понятный компилятору. Препроцессор развернул макрос, подставив его значение туда, где было указано его имя, и вдобавок убрал комментарий в начальной строчке.
Теперь рассмотрим еще один пример (листинг 1.3).
Листинг 1.3. Определение функционального макроса (ExtremeC_examples_chapter1_2.c)
#define ADD(a, b) a + b
int main(int argc, char** argv) {
int x = 2;
int y = 3;
int z = ADD(x, y);
return 0;
}
В приведенном выше коде, как и в примере 1.1, ADD не является функцией. Это всего лишь функциональный макрос, который принимает аргументы. После обработки препроцессором итоговый код будет выглядеть следующим образом (листинг 1.4).
Листинг 1.4. Пример 1.2 после обработки препроцессором и развертывания макроса
int main(int argc, char** argv) {
int x = 2;
int y = 3
int z = x + y;
return 0;
}
Как видите, произошло следующее развертывание: аргумент x, который использовался в качестве параметра a, был заменен всеми экземплярами a в значении макроса. То же самое произошло с параметром b и соответствующим ему аргументом y. Затем была произведена заключительная замена, и в обработанном препроцессором коде мы получили x+y вместо ADD(a,b).
Поскольку функциональные макросы могут принимать аргументы, с их помощью можно имитировать функции C. Иными словами, вы можете вынести часто используемую логику в функциональный макрос. Таким образом, препроцессор подставит вместо макроса часто применяемую логику и вам не нужно будет создавать новую функцию на языке C. Мы обсудим это более подробно и сравним оба подхода.
Макросы существуют только перед этапом компиляции. То есть компилятор теоретически ничего о них не знает. Это очень важный момент, о котором необходимо помнить, если вы собираетесь использовать макросы вместо функций. О функциях компилятору известно все, поскольку они являются частью грамматики языка C и хранятся в синтаксическом дереве. А макрос — просто директива, которую понимает только препроцессор.
Макросы позволяют генерировать код перед компиляцией. В других языках программирования, таких как Java, для этого применяются специальные генераторы кода. Я приведу примеры того, как это делается в контексте макросов.
Вопреки распространенному заблуждению, современные компиляторы C знают о директивах и анализируют исходный код еще до его обработки препроцессором. Взгляните на следующий пример (листинг 1.5).
Листинг 1.5. Определение макроса, которое вызывает ошибку «необъявленный идентификатор» (example.c)
#include <stdio.h>
#define CODE \
printf("%d\n", i);
int main(int argc, char** argv) {
CODE
return 0;
}
Если скомпилировать приведенный выше код с помощью clang в macOS, то получится следующий вывод (терминал 1.1).
Терминал 1.1. Вывод компилятора ссылается на определение макроса
$ clang example.c
code.c:7:3: error: use of undeclared identifier 'i'
CODE
^
code.c:4:16: note: expanded from macro 'CODE'
printf("%d\n", i);
^
1 error generated.
$
Как видите, компилятор сгенерировал сообщение об ошибке, в котором указана строчка с объявлением макроса.
Стоит отметить: большинство современных компиляторов позволяют просматривать результаты работы препроцессора непосредственно перед компиляцией. Например, задействуя gcc или clang, можно указать параметр -E, чтобы вывести обработанный препроцессором код. Пример использования параметра -E продемонстрирован в терминале 1.2. Обратите внимание: это лишь часть вывода.
Терминал 1.2. Код example.c после обработки препроцессором
$ clang -E example.c
# 1 "sample.c"# 1 "<built-in>" 1
# 1 "<built-in>" 3
# 361 "<built-in>" 3
...
# 412 "/Library/Developer/CommandLineTools/SDKs/MacOSX10.14.sdk/usr/include/
stdio.h" 2 3 4
# 2 "sample.c" 2
...
int main(int argc, char** argv) {
printf("%d\n", i);
return 0;
}
$
Мы подошли к важной концепции. Единица трансляции (или единица компиляции) — код на языке C, который прошел через препроцессор и готов к компиляции.
В единице трансляции все директивы заменены подключенными файлами или развернутыми макросами, благодаря чему получается один длинный блок кода на C.
Итак, вы уже познакомились с макросами. Теперь рассмотрим более сложные примеры, которые продемонстрируют всю эффективность и опасность данного механизма. Экстремальное программирование позволяет мастерски обращаться с опасными и тонкими концепциями, и это, как мне кажется, и есть суть языка C.
Ниже показан интересный пример. Обратите внимание на последовательное применение макросов в цикле (листинг 1.6).
Листинг 1.6. Использование макросов для генерации цикла (ExtremeC_examples_chapter1_3.c)
#include <stdio.h>
#define PRINT(a) printf("%d\n", a);
#define LOOP(v, s, e) for (int v = s; v <= e; v++) {
#define ENDLOOP }
int main(int argc, char** argv) {
LOOP(counter, 1, 10)
PRINT(counter)
ENDLOOP
return 0;
}
Как видите, код внутри функции main нельзя назвать корректным с точки зрения C! Но после обработки препроцессором мы получаем обычный исходный код, который компилируется без проблем. Ниже показан результат работы препроцессора (листинг 1.7).
Листинг 1.7. Код из примера 1.3 после обработки препроцессором
...
... содержимое stdio.h …
...
int main(int argc, char** argv) {
for (int counter = 1; counter <= 10; counter++) {
printf("%d\n", counter);
}
return 0;
}
В функции main листинга 1.6 для написания алгоритма использовался набор инструкций, непохожих на синтаксис C. Затем после работы препроцессора мы получили листинг 1.7 с полностью рабочим и корректным кодом. Это одна из важных областей применения макросов: создание новых предметно-ориентированных языков (domain specific language, DSL) и применение их для написания кода.
Языки DSL могут быть весьма полезными в разных частях проекта; например, они активно применяются в фреймворках для тестирования, таких как Google Test framework (gtest), где с их помощью оформляются определения, ожидания и тестовые сценарии.
Следует отметить: в итоговом коде, прошедшем через препроцессор, нет никаких директив. Это значит, что вместо директивы #include в листинге 1.6 было подставлено содержимое файла, на который она ссылалась. Именно поэтому перед функцией main в листинге 1.7 можно видеть содержимое заголовочного файла stdio.h (которое мы заменили многоточием).
Рассмотрим следующий пример с двумя новыми операторами для работы с параметрами макроса: # и ## (листинг 1.8).
Листинг 1.8. Использование операторов # и ## в макросе (ExtremeC_examples_chapter1_4.c)
#include <stdio.h>
#include <string.h>
#define CMD(NAME) \
char NAME ## _cmd[256] = ""; \
strcpy(NAME ## _cmd, #NAME);
int main(int argc, char** argv) {
CMD(copy)
CMD(paste)
CMD(cut)
char cmd[256];
scanf("%s", cmd);
if (strcmp(cmd, copy_cmd) == 0) {
// ...
}
if (strcmp(cmd, paste_cmd) == 0) {
// ...
}
if (strcmp(cmd, cut_cmd) == 0) {
// ...
}
return 0;
}
При развертывании макроса оператор # переводит параметр в строковую форму, заключенную в кавычки. Например, в приведенном выше листинге он превращает параметр NAME в "copy".
Оператор ## работает иначе. Он соединяет параметры с другими элементами в определении макроса — обычно в целях формирования имен переменных. Ниже показан исходный код примера 1.4 после обработки препроцессором:
Листинг 1.9. Пример 1.4 после обработки препроцессором
...
... содержимое stdio.h ...
...
... содержимое string.h ...
...
int main(int argc, char** argv) {
char copy_cmd[256] = ""; strcpy(copy_cmd, "copy");
char paste_cmd[256] = ""; strcpy(paste_cmd, "paste");
char cut_cmd[256] = ""; strcpy(cut_cmd, "cut");
char cmd[256];
scanf("%s", cmd);
if (strcmp(cmd, copy_cmd) == 0) {
}
if (strcmp(cmd, paste_cmd) == 0) {
}
if (strcmp(cmd, cut_cmd) == 0) {
}
return 0;
}
Чтобы понять, как операторы # и ## применяются к аргументам макроса, сравните код до и после работы препроцессора. Обратите внимание: в итоговом коде определение каждого макроса развертывается в одну строчку.
Длинные макросы рекомендуется разбивать на несколько строчек, однако не забывайте использовать обратную косую черту (\), чтобы препроцессор знал, что определение продолжается на следующей строчке. Обратите внимание: \ не заменяется символом новой строки. Это просто говорит о том, что следующая строчка — продолжение определения того же макроса.
Теперь обсудим разные типы макросов. Далее речь пойдет о вариативных макросах, которые могут принимать переменное число аргументов.
Вариативные макросы
Следующий пример посвящен вариативным макросам с переменным количеством входящих аргументов. Иногда вариативный макрос принимает два аргумента, иногда четыре, а иногда семь. Это может быть очень полезным, если вы не знаете заранее, сколько аргументов принимает ваш макрос в той или иной ситуации. Ниже показан простой пример (листинг 1.10).
Листинг 1.10. Определение и использование вариативного макроса (ExtremeC_examples_chapter1_5.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define VERSION "2.3.4"
#define LOG_ERROR(format, ...) \
fprintf(stderr, format, __VA_ARGS__)
int main(int argc, char** argv) {
if (argc < 3) {
LOG_ERROR("Invalid number of arguments for version %s\n.", VERSION);
exit(1);
}
if (strcmp(argv[1], "-n") != 0) {
LOG_ERROR("%s is a wrong param at index %d for version %s.", argv[1], 1,
VERSION);
exit(1);
}
// ...
return 0;
}
В этом листинге можно заметить новый идентификатор: __VA_ARGS__. Препроцессор подставляет вместо него все оставшиеся входящие аргументы, которые еще не были присвоены никаким параметрам.
Взгляните на второй экземпляр LOG_ERROR. Согласно определению макроса входящие аргументы argv[1], 1 и VERSIONне присваиваются ни одному из параметров. Поэтому при развертывании макроса они будут использованы вместо __VA_ARGS__.
Стоит отметить: функция fprintf производит запись в файловый дескриптор. В примере 1.5 роль этого дескриптора играет stderr — поток ошибок процесса. Вдобавок обратите внимание на точку с запятой после каждого экземпляра LOG_ERROR. Их использование связано с тем, что они не входят в состав макроса, поэтому программист должен добавлять их самостоятельно, чтобы после работы препроцессора код был синтаксически корректным.
Ниже показан итоговый код, который выводит препроцессор (листинг 1.11).
Листинг 1.11. Пример 1.5 после обработки препроцессором
...
... содержимое stdio.h ...
...
... содержимое stdlib.h ...
...
... содержимое string.h ...
...
int main(int argc, char** argv) {
if (argc < 3) {
fprintf(stderr, "Invalid number of arguments for version %s\n.", "2.3.4");
exit(1);
}
if (strcmp(argv[1], "-n") != 0) {
fprintf(stderr, "%s is a wrong param at index %d for version %s.",
argv[1], 1, "2.3.4");
exit(1);
}
// ...
return 0;
}
Ниже показан более сложный и довольно распространенный пример использования вариативных макросов с попыткой имитации цикла. Прежде чем в C++ появился цикл foreach, фреймворк boost предоставлял (и все еще предоставляет) его аналог на основе ряда макросов.
По следующей ссылке находится определение макроса BOOST_FOREACH (в самом конце заголовочного файла): https://www.boost.org/doc/libs/1_35_0/boost/foreach.hpp. Это функциональный макрос, который используется для итерации коллекций в boost.
В примере 1.6, показанном ниже, определен простой цикл, который и в подметки не годится циклу foreach из boost. Тем не менее это хорошая демонстрация того, как с помощью вариативных макросов можно повторять разные инструкции (листинг 1.12).
Листинг 1.12. Имитация цикла с помощью вариативных макросов (ExtremeC_examples_chapter1_6.c)
#include <stdio.h>
#define LOOP_3(X, ...) \
printf("%s\n", #X);
#define LOOP_2(X, ...) \
printf("%s\n", #X); \
LOOP_3(__VA_ARGS__)
#define LOOP_1(X, ...) \
printf("%s\n", #X); \
LOOP_2(__VA_ARGS__)
#define LOOP(...) \
LOOP_1(__VA_ARGS__)
int main(int argc, char** argv) {
LOOP(copy paste cut)
LOOP(copy, paste, cut)
LOOP(copy, paste, cut, select)
return 0;
}
Чтобы объяснить, как работает этот пример, нужно сначала взглянуть на итоговый код, прошедший обработку препроцессора. Так вам будет легче понять, что же на самом деле произошло (листинг 1.13).
Листинг 1.13. Пример 1.6 после обработки препроцессором
...
... содержимое stdio.h ...
...
int main(int argc, char** argv) {
printf("%s\n", "copy paste cut"); printf("%s\n", "");
printf("%s\n", "");
printf("%s\n", "copy"); printf("%s\n", "paste"); printf("%s\n", "cut");
printf("%s\n", "copy"); printf("%s\n", "paste"); printf("%s\n", "cut");
return 0;
}
Если внимательно присмотреться к преобразованному коду, то можно заметить, что макрос LOOP был развернут не в цикл наподобие for или while, а в повторяющиеся инструкции printf. Причина этого очевидна и связана с тем, что препроцессор не занимается написанием замысловатого кода на C. Он всего лишь заменяет макросы теми инструкциями, которые мы сами предоставили.
Имитировать цикл с помощью макроса можно только одним способом: разместить отдельные повторяющиеся инструкции одна за другой. Это значит, что простой цикл с 1000 итерациями превратится в 1000 инструкций в коде на C и в результате у нас не будет никакого реального цикла.
Использование представленного выше метода увеличивает размер двоичного файла, что можно считать недостатком. Размещение инструкций друг за другом вместо того, чтобы выполнять их в цикле, называется развертыванием цикла (loop unrolling) и хорошо подходит в определенных ситуациях: например, когда нужен приемлемый уровень производительности независимо от того, сколько ресурсов доступно в той или иной среде. Учитывая все вышесказанное, развертывание цикла можно считать компромиссом между размером двоичного файла и производительностью. Мы еще вернемся к этому далее.
Нужно сделать еще одно замечание относительно предыдущего примера. Как видите, разные способы использования макроса LOOP в функции main дали различные результаты. В первом случае мы передали copypastecut без запятых между словами. Препроцессор воспринял это выражение как одно входящее значение, и потому имитация цикла состояла всего из одной итерации.
Во втором случае было передано значение copy,paste,cut, но уже с запятыми между словами. Препроцессор решил, что это три отдельных аргумента, и потому имитация цикла имела три итерации. Наглядная демонстрация приведена в терминале 1.3.
В третьем случае из четырех переданных слов: copy,paste,cut,select — было обработано только три. После преобразования получился точно такой же код, как и во втором сценарии. Это объясняется тем фактом, что наши циклические макросы рассчитаны на списки не длиннее трех элементов. Все идущее после третьего значения игнорировалось.
Обратите внимание: это не вызывает никаких ошибок компиляции, поскольку итоговый сгенерированный код на C получается полностью корректным. Тем не менее наши макросы поддерживают ограниченное количество элементов.
Терминал 1.3. Компиляция и вывод примера 1.6
$ gcc ExtremeC_examples_chapter1_6.c
$ ./a.out
co py paste cut
copy
paste
cut
$
Преимущества и недостатки макросов
Для начала немного поговорим о проектировании программного обеспечения. Определение макросов и их совместное использование — своеобразное, порой увлекательное искусство! Прежде чем приступать к написанию макросов, вы пытаетесь представить, как будет выглядеть итоговый код. Этот механизм позволяет легко копировать и модифицировать ваш код, что располагает к злоупотреблению. Чрезмерное применение макросов может стать большой проблемой не только для вас, но и для ваших коллег. В чем же причина?
Макросы имеют важную особенность: перед компиляцией вместо них подставляются другие строчки, в результате чего вы получаете один длинный блок кода, лишенный всякой модульности. Конечно, модульность остается в вашем замысле и, наверное, в самом макросе, но в скомпилированных двоичных файлах ее нет. Вот тут-то макросы и начинают создавать архитектурные проблемы.
При проектировании ПО мы пытаемся упаковать похожие алгоритмы и концепции в несколько аккуратных модулей, которые можно использовать повторно, но макросы стремятся сделать все линейным и плоским. Поэтому если вы применяете их в качестве логических элементов архитектуры своего приложения, то информация о них может быть утеряна после работы препроцессора и не попасть в итоговые единицы трансляции. В связи с этим архитекторы и проектировщики ПО руководствуются следующим правилом.
Если макрос можно оформить в виде функции языка C, то делайте выбор в пользу функции!
Макросы считаются плохим решением и с точки зрения отладки. Разработчики постоянно используют ошибки компиляции (наряду с журнальными записями и иногда с предупреждениями компиляции) для поиска участков кода с неправильным синтаксисом. Предупреждения и ошибки компиляции помогают анализировать допущенные оплошности, и генерируются они компилятором.
Старые компиляторы языка C ничего не знали о макросах и обращались с компилируемым исходником (единицей трансляции) как с длинным, линейным, плоским блоком. Поэтому разработчик видел одно (оригинальный код на C с макросами), а компилятор — совершенно другое (преобразованный код без макросов). В результате вывод компилятора было сложно понять.
Есть надежда, что эта проблема уже не настолько серьезная. Популярные современные компиляторы C, такие как gcc и clang, знают больше о работе препроцессора и пытаются использовать в своих сообщениях тот исходный код, который видит разработчик. В остальном, если применять директивы #include, проблема остается, поскольку основное содержимое единицы трансляции известно лишь после подключения всех файлов. В заключение можно отметить, что проблема с отладкой не столь серьезная по сравнению со сложностями при проектировании ПО, которые мы описали несколькими абзацами выше.
Помните, о чем мы говорили при разборе примера 1.6? Речь шла о компромиссе между размером двоичного файла и производительностью программы. Более общая разновидность этого компромисса заключается в выборе между одним большим исполняемым файлом и множеством мелких. Оба варианта предоставляют одни и те же возможности, но первый может быть более производительным.
Количество двоичных файлов, используемых в проекте (особенно если он большой) более или менее прямо пропорционально уровню модульности и усилиям, потраченным на проектирование. Например, если проект состоит из 60 библиотек (динамических или статических), вызываемых из одной программы, то это значит, что план разработки подразумевал разбиение зависимостей на разные библиотеки с их последующим использованием в одном главном исполняемом файле.
Иными словами, когда проект разрабатывается в соответствии с общепринятыми принципами проектирования ПО, он обычно состоит из множества легковесных двоичных файлов с минимально допустимыми размерами, а не из одного огромного исполняемого файла.
В ходе проектирования мы не используем линейную структуру; каждый программный компонент получает подходящее место в развитой иерархии. И это по определению будет иметь отрицательное (хотя в большинстве случаев незначительное) влияние на производительность.
Из этого следует, что пример 1.6 на самом деле иллюстрирует баланс между архитектурой и производительностью. Если вам нужна высокая производительность, то иногда приходится жертвовать стройной архитектурой и размещать компоненты линейно. Например, вы можете развернуть свои циклы.
С другой стороны, обеспечение высокой производительности начинается с выбора подходящих алгоритмов для решения задач, определенных на этапе проектирования. Затем обычно идет оптимизация или улучшение производительности. На данном этапе производительность повышается за счет выполнения вычислений линейным и последовательным образом, без принудительных переходов на разные участки кода. Для этого можно использовать другие, производительные (и обычно более сложные) алгоритмы или модифицировать уже имеющиеся. Здесь может возникнуть конфликт с философией архитектуры. Как уже упоминалось ранее, в ходе проектирования мы пытаемся организовать компоненты в виде иерархии и избавиться от линейности, но центральный процессор рассчитан на то, что все инструкции уже загружены в линейном порядке и готовы к обработке. Поэтому данный компромисс необходимо продумать и сбалансировать для каждой отдельной задачи.
Обсудим развертывание циклов более подробно. Эта методика в основном применяется в разработке для встраиваемых систем и особенно в средах с ограниченными вычислительными ресурсами. Она представляет собой замену циклов линейными инструкциями, что улучшает производительность и избавляет от накладных расходов на циклическое выполнение.
Именно это мы и сделали в примере 1.6; мы имитировали цикл с помощью макросов, получив тем самым линейный набор инструкций. В каком-то смысле можно сказать, что макросы позволяют улучшить производительность во встраиваемых системах и средах, в которых даже незначительное изменение способа выполнения инструкций существенно повышает скорость работы. Более того, макросы могут сделать код более понятным и избавить нас от повторяющихся блоков кода.
Что касается приведенной ранее цитаты (утверждающей, что макросы следует заменять соответствующими функциями на языке C), то она относится к архитектуре, и в некоторых контекстах ее можно игнорировать. Например, если улучшение производительности является ключевым требованием, то линейный набор инструкций может оказаться необходимым.
Еще одна область применения макросов — генерация кода. Макросы позволяют внедрять в проект языки DSL. Microsoft MFC, Qt, Linux Kernel и wxWidgets — лишь несколько примеров из тысяч библиотек, которые определяют собственные языки DSL на основе макросов. Это в основном касается проектов на C++, однако они используют эту возможность языка C с целью организовать свои API.
В заключение нужно отметить: макросы в C могут быть положительным фактором, при условии, что их влияние на преобразованный препроцессором код хорошо изучено. Если вы участвуете в командной разработке проекта, то всегда интересуйтесь тем, как ваши коллеги применяют макросы, и не забывайте сообщать о своих решениях на этот счет.
Условная компиляция
Условная компиляция — еще одна уникальная особенность C. Она позволяет препроцессору генерировать разный исходный код в зависимости от тех или иных обстоятельств. Несмотря на название, компилятор не выполняет никаких условных действий — просто код, который передает ему препроцессор, может зависеть от определенных условий. Они проверяются препроцессором во время подготовки кода. В условной компиляции могут участвовать разные директивы. Их список приведен ниже:
• #ifdef;
• #ifndef;
• #else;
• #elif;
• #endif.
Простейший сценарий их использования показан в примере 1.7 (листинг 1.14).
Листинг 1.14. Пример условной компиляции (ExtremeC_examples_chapter1_7.c)
#define CONDITION
int main(int argc, char** argv) {
#ifdef CONDITION
int i = 0;
i++;
#endif
int j= 0;
return 0;
}
Обрабатывая приведенный выше код, препроцессор обнаруживает определение макроса CONDITION и делает пометку о том, что он определен. Обратите внимание: для этого макроса не указано никакого значения, и это совершенно корректно. Затем препроцессор опускается ниже и находит инструкцию #ifdef. Поскольку макрос CONDITION уже определен, все строчки между #ifdef и #endif попадают в итоговый исходный код.
Преобразованный результат показан в листинге 1.15.
Листинг 1.15. Пример 1.7 после обработки препроцессором
int main(int argc, char** argv) {
int i = 0;
i++;
int j= 0;
return 0;
}
Если бы макрос не был определен, то у директив #if-#endif не было бы никакой замены. Следовательно, преобразованный код выглядел бы приблизительно так (листинг 1.16).
Листинг 1.16. Пример 1.7 после обработки препроцессором в случае, если макрос CONDITION не определен
int main(int argc, char** argv) {
int j= 0;
return 0;
}
Обратите внимание на пустые строчки в листингах 1.15 и 1.16, оставшиеся после того, как препроцессор заменил участок #ifdef-#endif его вычисленным значением.
Макросы можно определять, передавая команде компиляции параметры -D. Например, в предыдущем случае макрос CONDITION можно было бы определить так:
$ gcc -DCONDITION -E main.c
Благодаря этой замечательной возможности вы можете определять свои макросы за пределами исходных файлов. Она особенно полезна, когда один и тот же исходник необходимо скомпилировать для разных архитектур, таких как Linux или macOS, с разными библиотеками и определениями макросов по умолчанию.
Директива #ifndef часто используется для предотвращения дублирования заголовков. Она не позволяет препроцессору подключить один и тот же заголовок дважды. Можно с уверенностью сказать, что она содержится в начале почти всех заголовочных файлов в практически любом проекте на C или C++.
Пример предотвращения дублирования заголовков приведен в листинге 1.17. Представьте, что это содержимое заголовочного файла и оно может быть включено в единицу компиляции два раза. Обратите внимание: пример 1.8 — лишь отдельный заголовочный файл, не предназначенный для компиляции.
Листинг 1.17. Пример защиты от дублирования заголовков (ExtremeC_examples_chapter1_8.h)
#ifndef EXAMPLE_1_8_H
#define EXAMPLE_1_8_H
void say_hello();
int read_age();
#endif
Как видите, определения всех переменных и функций находятся между #ifndef и #endif, что защищает их от повторного включения. Ниже я объясню, как это работает.
Когда заголовок подключается в первый раз, макрос EXAMPLE_1_8_H все еще не определен, поэтому препроцессор заходит в блок #ifndef-#endif. Следующая инструкция определяет макрос EXAMPLE_1_8_H, и препроцессор копирует в преобразованный код все, что находится выше директивы #endif. Когда происходит второе включение, макрос EXAMPLE_1_8_H уже определен, поэтому препроцессор пропускает все содержимое блока #ifndef-#endif и переходит к следующей за #endif инструкции, если таковая имеется.
Между между #ifndef и #endif обычно размещают все содержимое заголовочного файла, а снаружи остаются лишь комментарии.
В заключение следует сказать, что для защиты от двойного включения вместо пары #ifndef-#endif можно использовать одну директиву #pragmaonce. Ее единственной особенностью является то, что, несмотря на поддержку в почти всех препроцессорах, она не входит в стандарт C. Поэтому если ваш код должен быть переносимым, то от нее лучше отказаться.
В листинге 1.18 показано применение #pragmaonce вместо директив #ifndef-#endif.
Листинг 1.18. Использование директивы #pragma once в примере 1.8
#pragma once
void say_hello();
int read_age();
На этом обсуждение директив препроцессора можно считать завершенным. Я продемонстрировал некоторые их интересные особенности и способы применения. Следующий раздел посвящен еще одной важной возможности языка C — указателям на переменные.
Указатели на переменные
Указатели на переменные (или просто указатели) — одна из самых фундаментальных концепций в языке C. В большинстве высокоуровневых языков программирования они недоступны напрямую. Вместо них применяются аналогичные механизмы, такие как ссылки в Java. Стоит отметить, что указатели уникальны в том смысле, что адреса, на которые они указывают, могут использоваться напрямую аппаратным обеспечением, чего нельзя сказать об их высокоуровневых аналогах.
Глубокое понимание указателей и того, как они работают, — неотъемлемая черта любого квалифицированного программиста на C. Это один из ключевых аспектов управления памятью, и, несмотря на свой простой синтаксис, указатели в случае некорректного использования могут привести к катастрофическим последствиям. Темы, связанные с управлением памятью, рассматриваются в главах 4 и 5, а здесь мы сосредоточимся на указателях. Если вы уверенно ориентируетесь в основных терминах и концепциях, имеющих отношение к указателям, то можете пропустить этот раздел.
Синтаксис
В основе любого вида указателей лежит простая идея; это всего лишь обычная переменная, которая хранит адрес памяти. Первое, что приходит на ум при упоминании указателей, — это символ *, применяемый для их определения в C. Это показано в примере 1.9. В листинге 1.19 показано, как объявить и использовать указатель на переменную.
Листинг 1.19. Пример объявления и использования указателей в C (ExtremeC_examples_chapter1_9.c)
int main(int argc, char** argv) {
int var = 100;
int* ptr = 0;
ptr = &var;
*ptr = 200;
return 0;
}
В этом примере есть все, что необходимо знать о синтаксисе указателей. В первой строчке объявляется и помещается на вершину стека переменная var. О сегменте стека мы поговорим в главе 4. Во второй строчке объявляется указатель ptr, начальное значение которого равно нулю. Указатели, имеющие значение 0, называют нулевыми. Пока указатель ptr равен нулю, он считается нулевым. Если во время объявления указателю не присваивается действительный адрес, то его необходимо обнулить.
Как видите, в листинге 1.19 не подключаются никакие заголовочные файлы. Указатели — часть языка C, и для их использования не нужно ничего подключать. На самом деле программы на C могут обходиться без каких-либо заголовочных файлов.
Все эти объявления будут корректными с точки зрения C:
int* ptr = 0;
int * ptr = 0;
int *ptr = 0;
В третьей строке в функции main находится оператор &, который называют оператором указания или унарным оператором. Он возвращает адрес переменной, следующий за ним. Он нужен для получения адресов переменных, иначе мы не сможем правильно инициализировать наши указатели.
В той же строке возвращаемый адрес присваивается указателю ptr, который при этом перестает быть нулевым. В четвертой строке перед указателем находится еще один оператор, обозначенный символом *. Это оператор разыменования. Он открывает непрямой доступ к ячейке памяти, на которую указывает ptr. Иными словами, он позволяет считывать и модифицировать переменную var через указатель, который на нее ссылается. Эта строка эквивалентна выражению var=200;.
Нулевой указатель не содержит действительного адреса памяти. Вы должны избегать его разыменования, поскольку оно приводит к неопределенному поведению (что обычно заканчивается сбоем программы).
Заканчивая разбор предыдущего примера, отмечу, что при написании кода нам обычно доступен макрос NULL со значением 0, который можно использовать для обнуления указателей в момент их объявления (листинг 1.20). Этому макросу следует отдавать предпочтение, поскольку он помогает отличать обычные переменные от указателей.
Листинг 1.20. Обнуление указателя с помощью макроса NULL
char* ptr = NULL;
В C++ применяются точно такие же указатели, как и в C, и их тоже нужно обнулять, присваивая им 0 или NULL. Но в C++11 появилось новое ключевое слово для инициализации указателей. Это не макрос, как NULL, и не целое число, как 0. Речь идет о ключевом слове nullptr, которое позволяет обнулять указатели и проверять, являются ли они нулевыми. Пример его использования в C++11 показан в листинге 1.21.
Листинг 1.21. Обнуление указателя с помощью nullptr в C++11
char* ptr = nullptr;
Никогда не забывайте, что указатели нужно инициализировать во время объявления. Если вы не хотите с самого начала хранить в них действительный адрес памяти, то делайте их нулевыми, присваивая им 0 или NULL. В противном случае может возникнуть фатальная ошибка!
Большинство современных компиляторов сами обнуляют любые неинициализированные указатели. Это значит, все указатели до инициализации равны 0. Однако не думайте, будто это оправдывает объявление указателей без надлежащей инициализации. Помните, что вы пишете свой код для разных архитектур, старых и новых, и в устаревших системах это может привести к проблемам. Кроме того, большинство профилировщиков памяти, обнаружив неинициализированные указатели, выводят предупреждения и сообщения об ошибках. О профилировщиках мы подробно поговорим в главах 4 и 5.
Арифметические операции с указателями на переменные
Память проще всего представить себе в виде очень длинного одномерного байтового массива. Если следовать этому сравнению, то перемещаться по памяти можно только вперед и назад; других направлений не предусмотрено. То же самое касается указателей, ссылающихся на разные байты в памяти. Инкрементируя и декрементируя указатель, вы перемещаетесь вперед и назад соответственно. Это единственные арифметические операции, которые он поддерживает.
Аналогия, что арифметические операции с указателями подобны перемещениям по байтовому массиву, позволяет ввести новое понятие: длина арифметического шага. Оно нужно в связи с тем, что инкрементация указателя на 1 может привести к перемещению в памяти вперед больше чем на 1 байт. Длина арифметического шага есть у каждого указателя; она определяет, на сколько байтов он будет перемещаться при добавлении или вычитании значения 1. Эта величина определяется типом данных указателя.
На каждой платформе все указатели, хранящиеся в памяти, должны занимать ячейки определенного размера, то есть содержать одинаковое количество байтов. Но это вовсе не означает, что все они имеют одну и ту же длину арифметического шага. Как уже упоминалось выше, данная величина зависит от типа данных указателя.
Например, указатели типа int и char имеют одинаковый размер, но длина их арифметического шага отличается: int* обычно имеет четырехбайтный шаг, а char* — однобайтный. Следовательно, при инкрементации целочисленного указателя мы перемещаемся в памяти на четыре байта вперед (добавляем четыре байта к текущему адресу), а инкрементация указателя типа char приводит к перемещению вперед на 1 байт. В примере 1.10 демонстрируется длина арифметического шага для двух указателей с двумя разными типами данных (листинг 1.22).
Листинг 1.22. Длина арифметического шага для двух указателей (ExtremeC_examples_chapter1_10.c)
#include <stdio.h>
int main(int argc, char** argv) {
int var = 1;
int* int_ptr = NULL; // обнуляем указатель
int_ptr = &var;
char* char_ptr = NULL;
char_ptr = (char*)&var;
printf("Before arithmetic: int_ptr: %u, char_ptr: %u\n",
(unsigned int)int_ptr, (unsigned int)char_ptr);
int_ptr++; // арифметический шаг обычно равен 4 байтам
char_ptr++; // арифметический шаг равен 1 байту
printf("After arithmetic: int_ptr: %u, char_ptr: %u\n",
(unsigned int)int_ptr, (unsigned int)char_ptr);
return 0;
}
В терминале 1.4 показан вывод программы из примера 1.10. Имейте в виду, что при каждом запуске на разных платформах или даже на одном компьютере могут выводиться разные адреса, поэтому ваш вывод, скорее всего, будет отличаться.
Терминал 1.4. Вывод программы из примера 1.10 после первого запуска
$ gcc ExtremeC_examples_chapter1_10.c
$ ./a.out
Before arithmetic: int_ptr: 3932338348, char_ptr: 3932338348
After arithmetic: int_ptr: 3932338352, char_ptr: 3932338349
$
Если сравнить адреса до и после арифметических операций, то становится очевидно, что длина шага для целочисленного указателя равна 4 байтам, а для указателя типа char — одному. При следующем запуске указатели, вероятно, будут содержать какие-то другие адреса, но длина их арифметического шага останется прежней (терминал 1.5).
Терминал 1.5. Вывод программы из примера 1.10 после второго запуска
$ ./a.out
Before arithmetic: int_ptr: 4009638060, char_ptr: 4009638060
After arithmetic: int_ptr: 4009638064, char_ptr: 4009638061
$
Теперь, обсудив длину арифметического шага, мы можем рассмотреть классический образец использования арифметики указателей: перебор адресов на участке памяти. Примеры 1.11 и 1.12 должны выводить все элементы целочисленного массива; в первом применен тривиальный подход без использования указателей, а во втором задействованы арифметические операции, рассмотренные выше.
В листинге 1.23 представлен код примера 1.11.
Листинг 1.23. Перебор элементов массива без арифметики указателей (ExtremeC_examples_chapter1_11.c)
#include <stdio.h>
#define SIZE 5
int main(int argc, char** argv) {
int arr[SIZE];
arr[0] = 9;
arr[1] = 22;
arr[2] = 30;
arr[3] = 23;
arr[4] = 18;
for (int i = 0; i < SIZE; i++) {
printf("%d\n", arr[i]);
}
return 0;
}
Код в листинге 1.23 должен быть вам знаком. Обращение к определенному индексу массива и чтения его содержимого здесь выполняется с помощью счетчика цикла. Но если вместо индексов (целых чисел между [ и ]) вы хотите использовать указатели, то вам понадобится другой подход. В листинге 1.24 показано, как указатели позволяют перебирать элементы в пределах массива.
Листинг 1.24. Перебор элементов массива с помощью арифметики указателей (ExtremeC_examples_chapter1_12.c)
#include <stdio.h>
#define SIZE 5
int main(int argc, char** argv) {
int arr[SIZE];
arr[0] = 9;
arr[1] = 22;
arr[2] = 30;
arr[3] = 23;
arr[4] = 18;
int* ptr = &arr[0];
for (;;) {
printf("%d\n", *ptr);
if (ptr == &arr[SIZE - 1]) {
break;
}
ptr++;
}
return 0;
}
Второй способ, продемонстрированный в листинге 1.24, основан на бесконечном цикле, который прерывается, когда адрес указателя ptr равен адресу последнего элемента массива. Мы знаем, что массив — это набор смежных переменных, размещенных в памяти. И потому, инкрементируя и декрементируя указатель, мы можем перемещаться по массиву вперед и назад и таким образом обращаться к разным элементам.
Листинг 1.24 наглядно демонстрирует: указатель ptr имеет тип данных int*. Это вызвано тем фактом, что он должен иметь возможность указывать на каждый отдельный элемент массива. Обратите внимание: все элементы массива имеют один и тот же тип, int, вследствие чего их размеры должны совпадать. Следовательно, инкрементация указателя ptr смещает его к следующему элементу. Как видите, перед началом цикла for указателю ptr был присвоен адрес первого элемента, и в каждой итерации он перемещается вперед по участку памяти, который принадлежит массиву. Это самый что ни на есть классический пример использования арифметики указателей.
Следует отметить, что в C массив на самом деле является указателем на свой первый элемент. Поэтому в нашем примере переменная arr имеет тип данных int*. Следовательно, вместо:
int* ptr = &arr[0];
можно было бы написать:
int* ptr = arr;
Обобщенные указатели
Указатели типа void* называют обобщенными. Как и любые другие указатели, они могут ссылаться на любой адрес, но их фактический тип данных неизвестен, поэтому мы не знаем длину их арифметического шага. Обобщенные указатели обычно используются для хранения содержимого других указателей без запоминания их типов. В связи с этим их нельзя разыменовывать и с ними невозможно проводить арифметические операции, поскольку мы не знаем их тип данных. В примере 1.13 показана неудачная попытка разыменования обобщенного указателя (листинг 1.25).
Листинг 1.25. Разыменование обобщенного указателя вызывает ошибку компиляции (ExtremeC_examples_chapter1_13.c)
#include <stdio.h>
int main(int argc, char** argv) {
int var = 9;
int* ptr = &var;
void* gptr = ptr;
printf("%d\n", *gptr);
return 0;
}
Попробовав скомпилировать приведенный выше код с помощью gcc в Linux, вы получите следующее сообщение об ошибке (терминал 1.6).
Терминал 1.6. Компиляция примера 1.13 в Linux
$ gcc ExtremeC_examples_chapter1_13.c
In function 'main':
warning: dereferencing 'void *' pointer
printf("%d\n", *gptr);
^~~~~
error: invalid use of void expression
printf("%d\n", *gptr);
$
И если попытаться скомпилировать этот код с помощью clang в macOS, то получится другое сообщение об ошибке, которое сигнализирует о той же проблеме (терминал 1.7).
Терминал 1.7. Компиляция примера 1.13 в macOS
$ clang ExtremeC_examples_chapter1_13.c
error: argument type 'void' is incomplete
printf("%d\n", *gptr);
^
1 error generated.
$
Как видите, оба компилятора запрещают разыменование обобщенных указателей. На самом деле эта операция не имеет смысла! Для чего же могут понадобиться такие указатели? Что ж, они отлично подходят для определения обобщенных функций, которые могут принимать в качестве аргументов широкий спектр указателей. Это подробно продемонстрировано в примере 1.14 (листинг 1.26).
Листинг 1.26. Пример обобщенной функции (ExtremeC_examples_chapter1_14.c)
#include <stdio.h>
void print_bytes(void* data, size_t length) {
char delim = ' ';
unsigned char* ptr = data;
for (size_t i = 0; i < length; i++) {
printf("%c 0x%x", delim, *ptr);
delim = ',';
ptr++;
}
printf("\n");
}
int main(int argc, char** argv) {
int a = 9;
double b = 18.9;
print_bytes(&a, sizeof(int));
print_bytes(&b, sizeof(double));
return 0;
}
В приведенном выше листинге функция print_bytes принимает адрес в виде указателя void* и целое число, обозначающее длину. С помощью этих аргументов она выводит все байты, начиная с заданного адреса и вплоть до заданной длины. Функция принимает обобщенный указатель, благодаря чему пользователь может передать все, что ему вздумается. Имейте в виду: присваивание значений обобщенному (пустому) указателю не требует явного приведения типов. Именно поэтому я не указывал типы при передаче адресов a и b.
Внутри функции print_bytes используется указатель типа unsignedchar, позволяющий перемещаться по памяти. Никаких других прямых арифметических операций с параметром data совершать нельзя. Как вы уже, наверное, знаете, длина шагов char* и unsignedchar* равна 1 байту. Это делает данный тип указателей наиболее подходящим для побайтового перебора адресов в заданном диапазоне и их обработки байт за байтом.
В заключение стоит отметить, что size_t — это стандартный беззнаковый тип данных, который в языке C обычно используется для хранения размеров.
Функция size_t описана в подразделе 6.5.3.4 стандарта ISO/ICE 9899:TC3. Это знаменитая спецификация C99, пересмотренная в 2007 году. На сегодняшний день она является основой для всех реализаций C. Текст ISO/ICE 9899:TC3 (2007) находится по ссылке www.open-std.org/jtc1/sc22/wg14/www/docs/n1256.pdf.
Размер указателей
Если поискать в Google фразу «размер указателей в C», то обнаружится отсутствие однозначной информации по этому поводу. Вы найдете множество вариантов, но правда в том, что размер указателя не является понятием языка программирования как такового, а зависит от конкретной архитектуры. Язык C мало знает о подобных подробностях, относящихся к аппаратному обеспечению; он пытается предоставить универсальный способ работы с указателями и другими аспектами программирования. Именно поэтому язык C является стандартом; в нем описываются только сами указатели и их арифметика.
Под архитектурой имеется в виду оборудование, которое используется в компьютерной системе. Подробнее об этом — в главе 2.
Функцию sizeof всегда можно использовать для получения размера указателя. Достаточно проверить результат sizeof(char*) в вашей текущей архитектуре. В 32- и 64-битных архитектурах указатели, как правило, занимают соответственно 4 и 8 байт, однако в некоторых системах могут иметь другой размер. Помните: код, который вы пишете, не должен зависеть от конкретного размера указателя и не должен делать об этом никаких предположений. В противном случае у вас возникнут проблемы при его переносе на другие архитектуры.
Висячие указатели
Неправильное применение указателей вызывает множество хорошо известных проблем. Один из самых скверных примеров — висячие указатели. Указатель обычно содержит адрес участка памяти, выделенного для переменной. Чтение или изменение адреса, по которому не зарегистрировано никакой переменной, считается большой оплошностью и может привести к сбою программы или ошибке сегментации. Последнее — крайне неприятная проблема, с которой, наверное, сталкивался любой разработчик на C/C++. Она обычно возникает при неправильном использовании указателей. Вы обращаетесь к участку памяти, к которому у вас нет доступа. Раньше там находилась ваша переменная, но к моменту обращения она уже не существует.
Попробуем воспроизвести эту ситуацию в примере 1.15 (листинг 1.27).
Листинг 1.27. Попытка спровоцировать ошибку сегментации (ExtremeC_examples_chapter1_15.c)
#include <stdio.h>
int* create_an_integer(int default_value) {
int var = default_value;
return &var;
}
int main() {
int* ptr = NULL;
ptr = create_an_integer(10);
printf("%d\n", *ptr);
return 0;
}
В этом примере используется функция create_an_integer, которая создает целое число. Она объявляет переменную типа int со значением по умолчанию и возвращает ее адрес вызывающему коду. Функция main получает адрес созданного числа, var, и сохраняет его в указатель ptr. Затем указатель разыменовывается, а значение, хранящееся в var, выводится на экран.
Но не все так просто. Если скомпилировать этот код с помощью gcc на компьютере под управлением Linux, то он сгенерирует предупреждение, хотя сама компиляция завершится успешно и вы получите готовый исполняемый файл (терминал 1.8).
Терминал 1.8. Компиляция примера 1.15 в Linux
$ gcc ExtremeC_examples_chapter1_15.c
In function 'f':
warning: function returns address of local variable [-Wreturn-local-addr]
return &var;
^~~~
$
Это важное предупреждение, которое программист может легко упустить. Мы еще обсудим данную ситуацию позже, в главе 5. Посмотрим, что произойдет, если запустить полученный исполняемый файл.
При выполнении примера 1.15 возникает ошибка сегментации, что приводит к немедленному аварийному завершению программы (терминал 1.9).
Терминал 1.9. Ошибка сегментации при выполнении примера 1.15
$ ./a.out
Segmentation fault (core dumped)
$
Так в чем же проблема? Указатель ptr — висячий, поскольку участок памяти, на который он ссылается (принадлежавший переменной var), уже освобожден.
Переменная var локальная и освобождается сразу после завершения функции, в которой была объявлена, create_an_integer. Но вместе с тем эта функция возвращает ее адрес, который затем присваивается указателю ptr внутри main. В результате ptr становится висячим указателем, ссылаясь на недействительный участок памяти, а попытка его разыменования приводит к серьезной проблеме и аварийному завершению программы.
Если вернуться чуть назад, то можно увидеть, что компилятор явно предупреждает нас об этой проблеме.
Он говорит о возвращении адреса локальной переменной, которая освобождается после выхода из функции. Смышленый компилятор! Относитесь серьезно к его предупреждениям, и вам не придется иметь дело с жуткими ошибками.
Но как бы мы переделали этот пример? Конечно, используя кучу. Эту разновидность памяти мы подробно рассмотрим в главах 4 и 5, а пока исправим наш код с помощью выделения кучи и продемонстрируем преимущества данного подхода по сравнению с применением стека.
В примере 1.16 показано, как выделяются переменные в куче (листинг 1.28). Это позволяет передавать адреса между функциями без каких-либо проблем.
Листинг 1.28. Исправленная версия примера 1.15 с помощью кучи (ExtremeC_examples_chapter1_16.c)
#include <stdio.h>
#include <stdlib.h>
int* create_an_integer(int default_value) {
int* var_ptr = (int*)malloc(sizeof(int));
*var_ptr = default_value;
return var_ptr;
}
int main() {
int* ptr = NULL;
ptr = create_an_integer(10);
printf("%d\n", *ptr);
free(ptr);
return 0;
}
Как видите, мы подключили новый заголовочный файл, stdlib.h, и задействовали две новые функции: malloc и free. Простое объяснение звучит так: переменная, которая создается внутри функции create_an_integer, больше не является локальной. Она выделяется в куче, и ее время жизни не ограничено функцией, в которой была объявлена. Следовательно, к ней может обратиться вызывающая (внешняя) функция. Указатели, ссылающиеся на эту переменную, больше не висячие, и их можно разыменовывать (при условии, что переменная не была освобождена и все еще существует). В конце своего жизненного цикла переменная освобождается путем вызова функции free. Обратите внимание: любое содержимое кучи, которое больше не требуется, нужно обязательно освобождать.
В этом разделе мы обсудили все основные аспекты указателей на переменные. Далее мы поговорим о функциях и их принципе работы в C.
Общая информация о функциях
C — процедурный язык программирования. Функции в нем ведут себя подобно процедурам, играя роль основных элементов, из которых состоит программа. Поэтому необходимо разобраться в том, что они собой представляют и что происходит, когда вы входите в функцию и выходите из нее. В целом функции (или процедуры) можно считать обычными переменными, которые вместо значений хранят алгоритмы. Объединяя переменные и функции в новые типы, мы получаем возможность хранить значения и связанные с ними алгоритмы в виде одной сущности. Данный подход применяется в объектно-ориентированном программировании, и мы рассмотрим его в третьей части нашей книги. В этом же разделе мы хотим исследовать функции и обсудить их характеристики в языке C.
Анатомия функции
В этом разделе мы собрали воедино все, что касается функций в C. Если материал кажется вам знакомым, то можете перелистнуть страницы.
Функция — это логический блок с именем и списками принимаемых параметров и возвращаемых результатов. В C, как и во многих других языках программирования, на которые он повлиял, функции возвращают лишь одно значение. В объектно-ориентированных языках, таких как C++ и Java, функции обычно называются методами и, в отличие от C, могут генерировать исключения. Выполнение логики функции с помощью ее имени называется вызовом. Корректный вызов должен передать функции все аргументы, которые ей необходимы, и ждать, пока она не завершится. Обратите внимание: в C все функции блокирующие. Это значит, вызывающий код ждет, пока они не закончат работу, и только потом собирает возвращенные результаты.
Помимо блокирующих функций, существуют и неблокирующие. Вызывающий код может продолжать выполняться, не дожидаясь их завершения. В таком случае обычно используется механизм обратных вызовов, которые срабатывают, когда вызванная функция завершает работу. Неблокирующие функции также иногда называют асинхронными. Поскольку в C они не существуют, для их реализации применяются многопоточные решения. Мы обсудим эти концепции более подробно в пятой части книги.
Интересно, что в наши дни интерес к неблокирующим функциям продолжает расти. Существует целая область под названием «событийно-ориентированное программирование», и неблокирующие функции являются ее ключевым элементом, превосходя по частоте использования блокирующие аналоги.
В событийно-ориентированном программировании вызовы самих функций происходят внутри цикла событий, а обратные вызовы срабатывают при возникновении некоего события. Такой подход к программированию популяризуют библиотеки libuv и libev, которые позволяют проектировать ПО вокруг одного или нескольких событийных циклов.
Роль функций в архитектуре приложений
Функции — фундаментальные составные элементы процедурной разработки. Благодаря повсеместной поддержке в языках программирования они оказывают огромное влияние на то, как мы пишем свой код. Функции позволяют хранить логику в сущностях наподобие переменных и вызывать ее там и тогда, где и когда это нужно. Таким образом, мы можем написать один блок кода и многократно его использовать в разных местах.
Помимо прочего, функции позволяют изолировать друг от друга разные фрагменты логики. Иными словами, они вводят слой абстракции между различными логическими компонентами. Представьте, что у вас есть функция avg, которая вычисляет среднее значение для элементов входящего массива и вызывается из функции main. Считается, что логика внутри avg изолирована от логики внутри main.
Следовательно, если вы хотите поменять логику avg, то вам не нужно редактировать функцию main. Дело в том, что она полагается только на имя и доступность avg. Это было большим достижением, по крайней мере по тем временам, когда для написания и выполнения программ приходилось использовать перфокарты!
Этот подход до сих пор применяется в проектировании библиотек на C и даже в языках программирования более высокого уровня, таких как C++ и Java.
Управление стеком
Если взглянуть на участки памяти процессов, выполняющихся в Unix-подобной операционной системе, то можно заметить, что все они имеют похожую структуру. Мы подробно обсудим ее в главе 4, а пока познакомимся с одним из ее сегментов — стеком. Это участок памяти, который по умолчанию выделяется для всех локальных переменных, массивов и структур. Поэтому, когда вы объявляете локальную переменную в функции, она всегда создается в стеке — а если точнее, на его вершине.
Обратите внимание на название «стек» (stack — «стопка»). Этот сегмент ведет себя как набор элементов, которые складываются один поверх другого. Переменные и массивы всегда создаются на его вершине, а та переменная, которая находится в самом верху, удаляется первой. Мы еще вернемся к данной аналогии чуть ниже.
Стек используется еще и для вызова функций. Перед началом выполнения новой функции ее адрес возврата и все передаваемые ей аргументы собираются в стековый фрейм и кладутся на вершину стека. Когда эта функция завершается, фрейм извлекается из стека, после чего начинают выполняться инструкции, находящиеся по адресу возврата (это обычно приводит к продолжению работы вызывающей функции).
Все локальные переменные, объявленные в теле функции, помещаются на вершину стека. Как следствие, все они освобождаются по завершении функции. Именно поэтому мы называем их локальными переменными и можем быть уверены в том, что одна функция не имеет доступа к переменным другой. Этот механизм также объясняет, почему переменные не объявляются до входа в функцию и после выхода из нее.
Иметь понимание стека и того, как он работает, совершенно необходимо для написания корректного и осмысленного кода. Это также предотвращает распространенные ошибки, связанные с памятью. Стоит отметить, что в стеке нельзя создавать переменные любых размеров. Это ограниченная область памяти, которую можно заполнить до конца и получить ошибку переполнения стека. Обычно это происходит при вызове слишком большого количества функций, заполняющих все пространство стека стековыми фреймами. Подобная ситуация часто наблюдается во время работы с рекурсивными функциями, которые вызывают сами себя без каких-либо условий выхода или лимитов.
Передача по значению и передача по ссылке
В большинстве книг по программированию есть раздел, посвященный передаче аргументов в функцию: по значению или по ссылке. К счастью или к сожалению, в языке C доступен только первый вариант.
В C нет никаких ссылок, что делает передачу по ссылке невозможной. Все, что передается функции, копируется в ее локальные переменные и перестает быть доступным, когда она завершается.
Несмотря на многие примеры вызовов с передачей аргументов по ссылке, спешу вас уверить: в C это не более чем иллюзия. В оставшейся части данного подраздела я попытаюсь ее развеять и убедить вас в том, что во всех этих примерах аргументы передаются по значению. Это показано в листинге 1.29.
Листинг 1.29. Пример вызова функции с передачей по значению (ExtremeC_examples_chapter1_17.c)
#include <stdio.h>
void func(int a) {
a = 5;
}
int main(int argc, char** argv) {
int x = 3;
printf("Before function call: %d\n", x);
func(x);
printf("After function call: %d\n", x);
return 0;
}
Несложно предвидеть вывод этой программы. Переменная x не поменяется, поскольку передается по значению. Терминал 1.10 демонстрирует вывод примера 1.17 и подтверждает то, о чем мы и так догадывались.
Терминал 1.10. Вывод примера 1.17
$ gcc ExtremeC_examples_chapter1_17.c
$ ./a.out
Before function call: 3
After function call: 3
$
Как показывает пример 1.18 (листинг 1.30), в языке C не существует передачи по ссылке.
Листинг 1.30. Пример того, что передача по указателю отличается от передачи по ссылке (ExtremeC_examples_chapter1_18.c)
#include <stdio.h>
void func(int* a) {
int b = 9;
*a = 5;
a = &b;
}
int main(int argc, char** argv) {
int x = 3;
int* xptr = &x;
printf("Value before call: %d\n", x);
printf("Pointer before function call: %p\n", (void*)xptr);
func(xptr);
printf("Value after call: %d\n", x);
printf("Pointer after function call: %p\n", (void*)xptr);
return 0;
}
Мы получим следующий вывод (терминал 1.11).
Терминал 1.11. Вывод примера 1.18
$ gcc ExtremeC_examples_chapter1_18.c
$ ./a.out
The value before the call: 3
Pointer before function call: 0x7ffee99a88ec
The value after the call: 5
Pointer after function call: 0x7ffee99a88ec
$
Как видите, содержимое указателя не поменялось после вызова функции. Это значит, указатель передается по значению. Разыменование данного указателя внутри функции func позволяет обратиться к переменной, на которую он ссылается. Но вывод показывает, что изменение указателя внутри функции не меняет соответствующий аргумент в вызывающем коде. В языке C во время вызова все аргументы передаются по значению, а разыменовывание указателей позволяет менять переменные вызывающей функции.
Кроме того, стоит отметить, что в приведенном выше примере мы передаем не сами переменные, а их указатели. Обычно в качестве аргументов рекомендуется использовать указатели, а не крупные объекты, и несложно догадаться почему. Копирование восьмибайтного указателя куда более эффективно, чем копирование большого объекта, занимающего сотни байтов.
Удивительно, но в нашем примере эта эффективность себя не проявила! Причиной тому факт, что тип int занимает всего 4 байта, поэтому копируется быстрее, чем его восьмибайтный указатель. Но со структурами и массивами все иначе, ведь они копируются последовательно, байт за байтом, поэтому их лучше передавать по указателю.
Итак, мы рассмотрели некоторые аспекты функций в C. Теперь поговорим об их указателях.
Указатели на функции
Указатели на функции — еще одна супервозможность языка программирования C. Предыдущие два раздела были посвящены функциям и указателям на переменные. Здесь мы объединим обе темы и обсудим более интересную концепцию: указатели на функции.
Эти указатели можно использовать множеством способов, но один из самых важных заключается в разбиении одной большой программы на несколько мелких частей с последующей их загрузкой в отдельный компактный исполняемый файл. Эта методика лежит в основе модуляризации и проектирования ПО в целом. Указатели на функции используются в реализации полиморфизма в C++, позволяя расширять существующую логику. В данном разделе мы рассмотрим принцип их работы и подготовим вас к более сложным темам, которые будут представлены в следующих главах.
Указатель на переменную содержит ее адрес. Точно так же указатель на функцию содержит ее адрес, позволяя вызывать ее опосредованным образом. Начнем обсуждение этой темы с примера 1.19 (листинг 1.31).
Листинг 1.31. Вызов разных функций с помощью одного и того же указателя (ExtremeC_examples_chapter1_19.c)
#include <stdio.h>
int sum(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
int main() {
int (*func_ptr)(int, int);
func_ptr = NULL;
func_ptr = ∑
int result = func_ptr(5, 4);
printf("Sum: %d\n", result);
func_ptr = &subtract;
result = func_ptr(5, 4);
printf("Subtract: %d\n", result);
return 0;
}
В данном листинге func_ptr — указатель, который может ссылаться только на определенный вид функций, соответствующих его сигнатуре. Это значит, функции, адрес которых он содержит, должны принимать два целочисленных аргумента и возвращать целочисленный результат.
Как видите, мы определили две функции, sum и subtract, которые отвечают сигнатуре указателя func_ptr. Мы используем func_ptr, чтобы вызвать каждую из этих функций по отдельности с одними и теми же аргументами, и затем сравниваем результаты. В терминале 1.12 показан вывод данного примера.
Терминал 1.12. Вывод примера 1.19
$ gcc ExtremeC_examples_chapter1_19.c
$ ./a.out
Sum: 9
Subtract: 1
$
Как видно в примере 1.19, мы можем вызывать разные функции с одинаковыми аргументами, используя один указатель, и это важно. Если вы знакомы с объектно-ориентированным программированием, то на ум сразу приходят виртуальные функции и полиморфизм. На самом деле это единственный способ реализации полиморфизма в C с имитацией виртуальных функций C++. Мы поговорим об ООП в третьей части книги.
Указатели на функции, как и любые другие, необходимо как следует инициализировать. Если указатель не инициализируется непосредственно во время объявления, то его обязательно необходимо обнулить. Обнуление указателей на функции продемонстрировано в следующем примере. Это довольно похоже на то, как мы обнуляли указатели на перемены.
Для указателей на функции обычно рекомендуется определить новый псевдоним типа. В примере 1.20 показано, как это делается (листинг 1.32).
Листинг 1.32. Вызов разных функций с помощью одного указателя (ExtremeC_examples_chapter1_20.c)
#include <stdio.h>
typedef int bool_t;
typedef bool_t (*less_than_func_t)(int, int);
bool_t less_than(int a, int b) {
return a < b ? 1 : 0;
}
bool_t less_than_modular(int a, int b) {
return (a % 5) < (b % 5) ? 1 : 0;
}
int main(int argc, char** argv) {
less_than_func_t func_ptr = NULL;
func_ptr = &less_than;
bool_t result = func_ptr(3, 7);
printf("%d\n", result);
func_ptr = &less_than_modular;
result = func_ptr(3, 7);
printf("%d\n", result);
return 0;
}
Ключевое слово typedef позволяет определить псевдоним для уже существующего типа. В приведенном выше примере используются два таких псевдонима: bool_t для типа int и less_than_func_t для указателей на функции типа bool_t(*)(int,int). Благодаря этому код более понятен и можно выбрать более короткое имя для длинного и сложного типа. В C к именам новых типов принято добавлять _t; это соглашение об именовании соблюдается во многих других стандартных псевдонимах, таких как size_t и time_t.
Структуры
Структуры — одна из основополагающих концепций проектирования в C. В наши дни их аналоги можно встретить в практически любом современном языке программирования.
Структуры следует рассматривать в контексте истории развития информатики, начиная с тех далеких дней, когда C был единственным языком, который их поддерживал. Появление структур было огромным шагом навстречу инкапсуляции и одной из многочисленных мер по отказу от прямого использования машинных инструкций. Принципы, на которых основано человеческое мышление, не претерпели существенных изменений за последние тысячелетия; одним из них по-прежнему является инкапсуляция.
Но лишь с появлением C мы получили инструмент (в данном случае язык программирования), который соответствовал нашей картине мира и мог хранить и обрабатывать понятные нам логические элементы. Наконец-то у нас появился язык, соответствующий нашим мыслям и идеям, и все это благодаря структурам. Структуры в C были далеки от идеала, если сравнивать их с механизмами инкапсуляции современных языков, но их оказалось достаточно для построения платформы, на основе которой впоследствии были созданы наши самые лучшие инструменты.
Зачем нужны структуры
Вы, наверное, знаете, что у каждого языка программирования есть набор примитивных типов данных. С их помощью можно создавать собственные структуры данных и писать на их основе свои алгоритмы. Эти типы — часть языка, и их нельзя изменить или удалить. Например, из C невозможно убрать такие примитивные типы, как int и double.
Но если вы хотите определить собственные, пользовательскиетипы данных, не предусмотренные в самом языке, то вам помогут структуры. Это типы, которые создает пользователь, и они не входят в язык.
Обратите внимание: ключевое слово typedef не имеет никакого отношения к пользовательским типам. Оно лишь создает псевдоним для типов, которые уже существуют. Если вашей программе требуются совершенно новые типы, то вы должны задействовать структуры.
У структур есть аналоги в других языках программирования; например, в C++ и Java это классы, а в Perl — пакеты. Там они называются создателями типов.
Зачем нужны пользовательские типы
Так зачем же создавать в программе новые типы? Ответ на этот вопрос раскрывает принципы, стоящие за проектированием ПО, и методы, которые мы используем в повседневной разработке. Сами того не замечая, мы создаем новые типы у себя в уме, когда анализируем что-либо.
Мы не воспринимаем окружающий нас мир в виде чисел и символов. Наш мозг научился группировать связанные между собой атрибуты в отдельные объекты. Более подробно об этом мы поговорим в главе 6. Но, отвечая на первоначальный вопрос, новые типы нужны для анализа задач на более высоком логическом уровне, приближенном к человеческому мышлению.
Здесь необходимо упомянуть о понятии «бизнес-логика». Это набор всех сущностей и нормативно-правовых норм, которые встречаются в той или иной области. Например, бизнес-логика банковской системы состоит из таких концепций, как «клиент», «счет», «баланс», «деньги», «наличные», «платеж» и др. Все вместе они делают возможными и осмысленными, скажем, операции по снятию денег со счета.
Представьте, что вам нужно объяснить логику какой-нибудь банковской операции с помощью одних лишь чисел и символов. Это почти невозможно. Но даже если программист умудрится это сделать, полученный результат будет практически бессмысленным с точки зрения бизнес-аналитика. В реальных средах разработки с четко определенной бизнес-логикой программисты и бизнес-аналитики работают рука об руку. Поэтому им нужны общие терминология, глоссарий, типы, операции, нормы, логика и т.д.
В наши дни язык программирования, который не позволяет расширять свою систему типов, можно считать мертвым. Наверное, поэтому многие считают язык C таковым, ведь создание новых типов в нем, в отличие от более высокоуровневых аналогов, таких как C++ или Java, требует определенных усилий. Действительно, создать хорошую систему типов в C не так-то просто, но все, что для этого требуется, в нем уже есть.
Даже сегодня существует множество причин, чтобы выбрать C в качестве главного языка в своем проекте и смириться с тем, что для создания и поддержки хорошей системы типов потребуются значительные усилия. Многие компании так и делают.
Несмотря на то что новые типы постоянно требуются в ходе анализа ПО, центральные процессоры их не понимают. Аппаратное обеспечение пытается ограничиваться примитивными типами и быстрыми вычислениями, поскольку создано именно для этого. Как следствие, если ваша программа написана на высокоуровневом языке, то должна быть преобразована в набор процессорных инструкций, что может потребовать дополнительного времени и ресурсов.
К счастью, язык C в этом смысле не далеко ушел от логики уровня процессора, и его систему типов можно легко преобразовать. Вы, наверное, слышали, что C — низкоуровневый язык программирования, близкий к машинным кодам. Это одна из причин, почему некоторые компании до сих пор стремятся писать и поддерживать свои основные фреймворки на C.
Принцип работы структур
Структуры инкапсулируют связанные между собой значения в одном общем типе. В качестве простейшего примера мы можем сгруппировать переменные red, green и blue в новый отдельный тип данных под названием color_t. Этот новый тип может использоваться для представления цвета в формате RGB во многих программах, таких как графические редакторы. Соответствующую структуру в C можно определить следующим образом (листинг 1.33).
Листинг 1.33. Структура в C, представляющая цвет в формате RGB
struct color_t {
int red;
int green;
int blue;
};
Как уже упоминалось, структуры выполняют инкапсуляцию — один из основополагающих принципов проектирования ПО. Он заключается в группировании и изоляции связанных между собой полей в рамках нового типа. Затем этот тип можно использовать для определения нужных нам переменных. Мы рассмотрим данную тему во всех подробностях в ходе обсуждения объектно-ориентированного проектирования в главе 6.
Обратите внимание: в именах новых типов данных используется суффикс _t.
Размещение структур в памяти
Обычно программистам на C необходимо знать, как именно выглядит структурная переменная в памяти. В некоторых архитектурах неэффективное размещение структур в памяти может привести к ухудшению производительности. Не забывайте, что наш код превращается в процессорные инструкции. Значения хранятся в памяти, и процессор должен иметь возможность их быстро считывать и записывать. Если программист знает, как устроена память его программы, то ему будет легче понять, как работает центральный процессор, и адаптировать свой код, чтобы получить лучшие результаты.
В примере 1.21 мы создаем новый структурный тип, sample_t, и объявляем одну структурную переменную, var. Затем заполняем поля этой переменной некоторыми значениями и выводим ее размер и байты, которые она хранит. Таким образом, мы сможем продемонстрировать то, как она представлена в памяти (листинг 1.34).
Листинг 1.34. Вывод количества байтов, выделенных для структурной переменной (ExtremeC_examples_chapter1_21.c)
#include <stdio.h>
struct sample_t {
char first;
char second;
char third;
short fourth;
};
void print_size(struct sample_t* var) {
printf("Size: %lu bytes\n", sizeof(*var));
}
void print_bytes(struct sample_t* var) {
unsigned char* ptr = (unsigned char*)var;
for (int i = 0; i < sizeof(*var); i++, ptr++) {
printf("%d ", (unsigned int)*ptr);
}
printf("\n");
}
int main(int argc, char** argv) {
struct sample_t var;
var.first = 'A';
var.second = 'B';
var.third = 'C';
var.fourth = 765;
print_size(&var);
print_bytes(&var);
return 0;
}
Стремление понять, как тот или иной компонент размещен в памяти, в основном присуще программистам на C/C++ и редко встречается среди тех, кто пишет на более высокоуровневых языках программирования. Например, в Java и Python программисты обычно не так хорошо понимают всю подноготную управления памятью; с другой стороны, указанные языки не предоставляют особо подробных сведений на этот счет.
Как можно видеть в листинге 1.34, перед объявлением структурной переменной в C необходимо указывать ключевое слово struct. В приведенном выше примере это продемонстрировано с помощью выражения structsample_tvar, в котором struct находится перед структурным типом. Вы, наверное, уже знаете, что для доступа к полям структурной переменной используется символ . (точка) или -> (стрелка), если речь идет о структурном указателе.
Если вы не хотите набирать struct при определении каждого нового структурного типа и объявлении каждой структурной переменной, то можете создать для своих структур псевдонимы. Используйте для этого typedef, как показано ниже:
typedef struct {
char first;
char second;
char third;
short fourth;
} sample_t;
Теперь вы можете объявить переменную, не прибегая к использованию ключевого слова struct:
sample_t var;
В терминале 1.13 показан вывод примера 1.21 после его компиляции и запуска на компьютере под управлением macOS. Обратите внимание: генерируемые числа могут варьироваться в зависимости от системы.
Терминал 1.13. Вывод примера 1.21
$ clang ExtremeC_examples_chapter1_21.c
$ ./a.out
Size: 6 bytes
65 66 67 0 253 2
$
Как видите, выражение sizeof(sample_t) вернуло 6 байт. По своему размещению в памяти структурная переменная очень похожа на массив. В нем все элементы размещаются в памяти последовательно; то же самое относится к структурной переменной и ее полям. Разница лишь в том, что в массиве все элементы имеют один и тот же тип и, следовательно, одинаковый размер, чего нельзя сказать о структурных переменных. Каждое поле может иметь свой тип и размер. Мы можем легко вычислить, какой объем памяти занимает массив, но размер структурной переменной зависит от нескольких факторов и не является столь очевидным.
На первый взгляд в определении размера структурной переменной нет ничего сложного. В нашем предыдущем примере структура состоит из четырех полей: трех — типа char и одного — типа short. Если предположить, что sizeof(char) равно 1 байту, а sizeof(short) — двум, то после простого подсчета можно утверждать, что тип sample_t должен занимать в памяти 5 байт. Но в нашем выводе видно: sizeof(sample_t) равно 6 байтам. На один больше! Откуда взялась эта разница?
Опять же, если взглянуть на содержимое структурной переменной var в памяти, можно увидеть, что оно выглядит не так, как можно было бы ожидать (а именно 6566672532).
Чтобы прояснить ситуацию и объяснить, почему размер структурной переменной не равен 5 байтам, следует ввести понятие выравнивания данных в памяти. Прежде чем совершать какие-либо вычисления, процессор должен сначала загрузить из памяти подходящие значения, а затем сохранить полученный результат обратно в память. Вычисления сами по себе происходят невероятно быстро, но доступ к памяти выполняется сравнительно медленно. Понимание того, как процессор взаимодействует с памятью, может пригодиться при оптимизации и отладке программы.
Обычно при каждом обращении к памяти процессор считывает определенное количество байтов. Эту величину принято называть машинным словом. Таким образом, память поделена на слова, каждое из которых является атомарной единицей чтения и записи. Количество байтов в машинном слове зависит от архитектуры. Например, в большинстве 64-битных компьютеров слово занимает 32 бита, или 4 байта. Касательно выравнивания принято считать, что переменная выровнена в памяти, если ее первый байт совпадает с началом машинного слова. Так процессор может оптимизировать обращения к памяти, необходимые для загрузки ее значения.
Вернемся к примеру 1.21. Каждое из первых трех полей, first, second и third, занимает 1 байт и находится в первом машинном слове структуры; все они могут быть прочитаны за одно обращение к памяти. А вот четвертое поле, fourth, занимает 2 байта. Если забыть о выравнивании данных в памяти, то его начальный байт должен стать последним байтом первого слова.
В подобном случае для загрузки значения данного поля из памяти процессору пришлось бы выполнить два обращения к памяти и заодно сместить определенные биты. Именно поэтому мы видим дополнительный ноль после байта 67. Этот нулевой байт был добавлен с целью завершить текущее слово и начать новое с четвертого поля. Следовательно, мы можем сказать, что первое машинное слово было дополнено одним нулевым байтом. Компилятор использует дополнительные байты для выравнивания значений в памяти.
Выравнивание можно отключить. В терминологии языка C невыровненные структуры называются упакованными, и их использование может привести к двоичной несовместимости и ухудшению производительности. Определить упакованную структуру довольно легко. Мы покажем, как это делается, в примере 1.22; от 1.21 он отличается только тем, что структура sample_t в нем упакована. Обратите внимание: похожий код здесь заменен многоточием (листинг 1.35).
Листинг 1.35. Объявление упакованной структуры (ExtremeC_examples_chapter1_22.c)
#include <stdio.h>
struct __attribute__((__packed__)) sample_t {
char first;
char second;
char third;
short fourth;
} ;
void print_size(struct sample_t* var) {
// ...
}
void print_bytes(struct sample_t* var) {
// ...
}
int main(int argc, char** argv) {
// ...
}
В терминале 1.14 показано, как этот код компилируется с помощью clang и запускается в macOS.
Терминал 1.14. Вывод примера 1.22
$ clang ExtremeC_examples_chapter1_22.c
$ ./a.out
Size: 5 bytes
65 66 67 253 2
$
Как видите, здесь выводится именно тот размер, который мы ожидали увидеть в примере 1.21. Итоговое размещение структуры в памяти тоже соответствует нашим ожиданиям. Упакованные структуры обычно применяются в средах, в которых память является дефицитным ресурсом; при этом они могут существенно ухудшить производительность в большинстве архитектур. Только новые процессоры могут читать невыровненные значения, занимающие несколько машинных слов, не расходуя лишние ресурсы. Выравнивание данных в памяти по умолчанию включено.
Вложенные структуры
Как я уже объяснил в предыдущих разделах, типы данных в C можно разделить на две основные категории. Одни являются примитивными и встроенными в язык, а другие определяются программистами с помощью ключевого слова struct. Первые называются PDT (primitive data types — примитивные типы данных), a вторые — UDT (user data types — пользовательские типы данных).
До сих пор UDT (структуры) в наших примерах состояли только из PDT. Но в этом подразделе мы покажем примеры структур, которые состоят из других структур. Такие UDT называются сложными типами данных и являются результатом вложенности пользовательских типов.
Начнем с примера 1.23 (листинг 1.36).
Листинг 1.36. Объявление вложенных структур (ExtremeC_examples_chapter1_23.c)
typedef struct {
int x;
int y;
} point_t;
typedef struct {
point_t center;
int radius;
} circle_t;
typedef struct {
point_t start;
point_t end;
} line_t;
В этом листинге у нас есть три структуры: point_t, circle_t и line_t. Первая является простым пользовательским типом и состоит только из примитивных полей. Но поля двух других структур имеют тип point_t, что делает их сложными пользовательскими типами.
Размер сложных и простых структур вычисляется одним и тем же способом: путем сложения размеров всех полей. Конечно, не стоит забывать о выравнивании, которое может повлиять на размер сложной структуры. Так, если sizeof(int) равно 4 байтам, то sizeof(point_t) будет равно 8. Точно так же sizeof(circle_t) и sizeof(line_t) равны 12 и 16 байтам соответственно.
Структурные переменные часто называют объектами. Они выступают прямым аналогом объектов в объектно-ориентированном программировании, и вы увидите, что, помимо значений, они могут инкапсулировать и функции. Поэтому такое название вполне обоснованно.
Указатели на структуры
Указатели могут ссылаться не только на PDT, но и на UDT. Работает это точно так же: указатель содержит адрес памяти и с ним можно выполнять арифметические операции, как мы делали это с указателями на примитивные типы. Длина арифметического шага в случае с UDT эквивалентна размеру структуры. Если вы плохо ориентируетесь в указателях и арифметических операциях, которые они поддерживают, то, пожалуйста, прочитайте соответствующий раздел на с. 44.
Необходимо понимать: структурный указатель ссылается на адрес первого поля структурной переменной. В примере 1.23 указатель типа point_t хранил адрес своего первого поля, x. То же самое касается типа circle_t. Указатель на circle_t хранил адрес своего первого поля, center; но, поскольку это поле в действительности являлось объектом point_t, данный указатель фактически ссылался на первое поле этого объекта, x. Таким образом, у нас может быть три указателя с адресом одной и той же ячейки памяти. Это показано в листинге 1.37.
Листинг 1.37. Три разных указателя трех разных типов ссылаются на один и тот же байт памяти (ExtremeC_examples_chapter1_24.c)
#include <stdio.h>
typedef struct {
int x;
int y;
} point_t;
typedef struct {
point_t center;
int radius;
} circle_t;
int main(int argc, char** argv) {
circle_t c;
circle_t* p1 = &c;
point_t* p2 = (point_t*)&c;
int* p3 = (int*)&c;
printf("p1: %p\n", (void*)p1);
printf("p2: %p\n", (void*)p2);
printf("p3: %p\n", (void*)p3);
return 0;
}
Получится следующий вывод (терминал 1.15).
Как видите, все указатели ссылаются на один и тот же байт, но их типы отличаются. Такой подход обычно используется для расширения структур, которые берутся из внешних библиотек (то есть для добавления к ним новых полей). К тому же именно так мы реализуем наследование в C. Мы вернемся к этому в главе 8.
Терминал 1.15. Вывод примера 1.24
$ clang ExtremeC_examples_chapter1_24.c
$ ./a.out
p1: 0x7ffee846c8e0
p2: 0x7ffee846c8e0
p3: 0x7ffee846c8e0
$
Это был последний раздел данной главы. Далее мы займемся процессом компиляции и покажем, как правильно скомпилировать и скомпоновать проект, написанный на языке C.
Резюме
В текущей главе мы рассмотрели некоторые важные особенности языка программирования C. Мы попытались копнуть глубже и показать архитектурные аспекты этих возможностей и стоящие за ними концепции. Конечно, их эффективное использование требует более глубокого и разностороннего понимания. Ниже указаны темы, которые мы обсудили.
• На поведение препроцессора C можно влиять с помощью различных директив. Благодаря этому мы можем генерировать нужный исходный код.
• Макросы и механизм их развертывания позволяет генерировать код на языке C до передачи единицы трансляции компилятору.
• Условные директивы позволяют модифицировать код, который проходит через препроцессор, с учетом определенных условий. В результате мы можем создать разный код для разных ситуаций.
• Мы также рассмотрели указатели на переменные и их использование в C.
• Вы познакомились с обобщенными указателями и узнали, как написать функцию, которая принимает указатели любых типов.
• Мы обсудили некоторые проблемы, такие как ошибки сегментации и висячие указатели, чтобы увидеть несколько катастрофических ситуаций, которые могут возникнуть при неправильном использовании указателей.
• Затем мы поговорили о функциях и их синтаксисе.
• Мы исследовали архитектурные аспекты функций и то, какую роль они играют в хорошо спроектированных процедурных программах на C.
• Был также объяснен механизм вызова функций и то, как стековые фреймы применяются для передачи аргументов.
• Мы рассмотрели указатели на функции. Их гибкий синтаксис позволяет сохранять логику в сущностях, подобных переменным, и использовать ее в дальнейшем. На самом деле это фундаментальный механизм, с помощью которого загружается и выполняется любая современная программа.
• Структуры в сочетании с указателями на функции привели к появлению в C инкапсуляции. Более подробно об этом — в третьей части.
• Мы попытались выяснить архитектурные аспекты структур и их влияние на процесс проектирования программ в C.
• Вдобавок мы обсудили размещение в памяти структурных переменных и то, как это можно оптимизировать для максимально эффективного использования процессора.
• Помимо этого, были затронуты вложенные структуры. Мы заглянули внутрь сложных структурных переменных и увидели, как они должны размещаться в памяти.
• Заключительный раздел этой главы был посвящен указателям на структуры.
Глава 2 станет нашим первым шагом на пути к созданию проекта на C. В ней мы обсудим процесс компиляции и механизм компоновки. Чтобы перейти к следующим главам, необходимо внимательно прочитать изложенный в ней материал.
2. Компиляция и компоновка
В программировании все начинается с исходного кода. На самом деле исходный код, который еще иногда называют кодовой базой, обычно состоит из целого ряда текстовых файлов. Каждый такой файл содержит текстовые инструкции, написанные на языке программирования.
Мы уже знаем, что центральный процессор не умеет выполнять текстовые инструкции. Сначала их нужно скомпилировать (или транслировать) в машинные коды, выполнение которых обеспечивает работу программы.
В данной главе мы пошагово разберем весь процесс превращения исходного кода на языке C в готовый продукт компиляции. Это очень глубокая тема, и потому я разбил ее на пять разделов.
1. Стандартный процесс компиляции в C. Здесь мы узнаем, как обычно происходит компиляция в C, из каких этапов она состоит и какова их роль в создании готового продукта из исходного кода.
2. Препроцессор. Здесь мы во всех подробностях обсудим препроцессор, который обрабатывает код перед компиляцией.
3. Компилятор. Здесь будет рассказано, как компиляторы создают промежуточное представление исходного кода и затем транслируют его в язык ассемблера.
4. Ассемблеры. Вслед за компиляторами перейдем к ассемблерам, которые играют важную роль в трансляции ассемблерных инструкций, полученных из компилятора, в машинные коды.
5. Компоновщику посвящен заключительный раздел. Так называется механизм сборки, который создает готовые продукты из проектов на C. На данном этапе могут возникать специфические ошибки, и их предотвращение и исправление потребует от вас глубокого понимания этого инструмента. Мы также обсудим разные виды файлов, которые можно получить в результате компоновки одного и того же проекта, и будет дано несколько советов о дизассемблировании объектных файлов и анализе их содержимого. Более того, мы затронем декорирование имен в C++ и увидим, как оно помогает избежать определенных эффектов на этапе компоновки в проектах на C++.
Материал этой главы в основном предназначен для Unix-подобных операционных систем, но мы обсудим и особенности других ОС, таких как Microsoft Windows.
В первом разделе нам нужно объяснить процесс компиляция в языке C. Вы обязательно должны понимать, как в результате исходный код превращается в исполняемые и библиотечные файлы. Чтобы продолжать чтение этой и последующих глав, необходимо знать, на каких концепциях основан данный процесс и из каких этапов он состоит. Обратите внимание: продукты компиляции проекта на C подробно обсуждаются в главе 3.
Процесс компиляции
Компиляция файлов, написанных на языке C, обычно длится лишь несколько секунд, но за это время исходный код успевает пройти процесс обработки с участием четырех отдельных компонентов, каждый из которых имеет определенное назначение:
• препроцессор;
• компилятор;
• ассемблер;
• компоновщик.
Каждый из них принимает определенный ввод от предыдущего компонента и генерирует определенный вывод для следующего. Этот процесс продолжается, пока последний компонент не сгенерирует итоговый продукт.
Исходный код превращается в продукт только при успешном прохождении всех необходимых этапов. Это значит, что даже небольшой сбой в одном из компонентов может привести к ошибкам компиляции или компоновки, сообщения о которых вы увидите на экране.
Чтобы получить некоторые промежуточные продукты, такие как переносимые объектные файлы, достаточно успешно пройти первые три этапа. Последний, компоновка, обычно используется для создания более крупных продуктов; например, исполняемый файл формируется путем слияния нескольких объектных. Поэтому в результате сборки исходных файлов на языке C может получиться один или несколько объектных файлов, включая переносимые, исполняемые и разделяемые.
На сегодня у языка C есть много разных компиляторов. Одни из них свободные, с открытым исходным кодом, другие являются коммерческими, проприетарными решениями. Некоторые компиляторы предназначены строго для одной платформы, в то время как существуют и кросс-платформенные, хотя следует отметить, что почти на любой платформе есть по крайней мере один компилятор для C.
Полный список доступных компиляторов можно найти в «Википедии»: https://en.wikipedia.org/wiki/List_of_compilers#C_compilers.
Прежде чем рассказывать о том, какие систему и компилятор мы будем использовать в этой главе, сначала я уделю некоторое внимание термину «платформа» и объясню, что мы понимаем под ним.
Платформа — сочетание операционной системы и оборудования (или архитектуры), самая важная часть которого — набор инструкций центрального процессора. Операционная система играет роль программного компонента платформы, а аппаратный компонент определяется архитектурой. Например, мы можем иметь дело с ОС Ubuntu на ARM-плате или с Microsoft Windows на компьютере с 64-битным процессором AMD.
Кросс-платформенное программное обеспечение может работать на разных платформах. Но при этом необходимо понимать, что кросс-платформенность отличается от переносимости. Кросс-платформенное ПО обычно предоставляет разные двоичные (итоговые объектные) файлы и установщики для каждой среды, тогда как переносимые программы везде используют одни и те же исполняемые и установочные файлы.
Некоторые компиляторы для C, такие как gcc и clang, являются кросс-платформенными — они умеют генерировать код для разных платформ. А вот Java создает переносимый байт-код.
В контексте C/C++ переносимость означает, что мы можем скомпилировать свой исходный код для разных платформ, не нуждаясь во внесении каких-либо (даже малейших) изменений. Но мы вовсе не имеем в виду, что переносимыми будут итоговые объектные файлы.
В статье на «Википедии», упоминаемой выше, приводится несметное количество компиляторов для C. К счастью, все они поддерживают стандартный процесс компиляции, с которым вы познакомитесь в текущей главе.
Несмотря на столь богатый выбор, в этой главе мы должны остановиться на каком-то одном варианте. В качестве компилятора по умолчанию мы будем использовать gcc 7.3.0. Выбор пал на него ввиду доступности в большинстве операционных систем; к тому же ему посвящено много онлайн-ресурсов.
Нам также нужно определиться с платформой по умолчанию. В данной главе мы будем использовать операционную систему Ubuntu 18.04 и 64-битный процессор AMD в качестве архитектуры.
В этой главе время от времени будут упоминаться другие компиляторы, операционные системы и архитектуры — для сравнения разных платформ и инструментов. В таких случаях заранее указывается, о какой платформе или компиляторе идет речь.
В следующих разделах я пошагово опишу процесс компиляции. Вначале я покажу небольшой пример компиляции и компоновки исходных текстов в проекте C. В ходе этого вы познакомитесь с новыми терминами и концепциями, относящимися к сборке программ. Только потом каждый компонент будет рассмотрен в отдельном разделе. Там вы найдете подробную информацию с акцентом на внутренние концепции и процессы.
Сборка проекта на языке C
В данном подразделе демонстрируется, как собрать проект на C. Пример, с которым мы будем работать, состоит из нескольких исходных файлов, что характерно почти для всех проектов на этом языке. Но прежде, чем переходить к сборке, сначала рассмотрим структуру типичного проекта на C.
Заголовочные и исходные файлы
Любой проект на C содержит исходный код (или кодовую базу) и другие документы, которые описывают разрабатываемое приложение и используемые стандарты. Код C обычно хранится в файлах двух типов:
• в заголовочных файлах, как правило, имеющих расширение .h;
• в исходных файлах с расширением .c.
Для краткости заголовочные файлы в этой главе называются заголовками, а исходные файлы — исходниками.
Заголовочный файл обычно содержит перечисления, макросы и определения типов, а также объявления функций, глобальных переменных и структур. В языке C объявление и определение некоторых элементов программирования, таких как функции, переменные и структуры, могут находиться в разных файлах.
В C++ используется тот же принцип, но в других языках программирования, таких как Java, элементы определяются там, где объявлены. Эта замечательная черта C и C++ позволяет отделить объявление от определения, но вместе с тем может сделать исходный код более сложным.
Объявления принято хранить в заголовочных файлах, а соответствующие определения — в исходных. Это особенно относится к функциям.
Объявления функций настоятельно рекомендуется размещать в заголовках, а их определения — в соответствующих исходниках. И хотя это не является обязательным требованием, такой подход к проектированию позволит вам хранить определения функций вне заголовочных файлов.
Определения структур и объявления можно хранить и в разных файлах, но это делается в особых случаях. Соответствующий пример будет рассмотрен в главе 8, в которой мы обсудим наследование классов.
К заголовочным файлам можно подключать только другие заголовки, но не исходники. К исходным файлам можно подключать только заголовки. Подключение одних заголовочных файлов к другим считается дурным тоном. Если вы так делаете, то это обычно говорит о серьезной проблеме в архитектуре вашего проекта.
Чтобы лучше понять эту тему, рассмотрим пример. В листинге 2.1 показан код с объявлением функции average, состоящий из ее возвращаемого типа и сигнатуры. Сигнатура — просто имя функции со списком ее входных параметров.
Листинг 2.1. Объявление функции average
double average(int*, int);
Здесь мы видим сигнатуру функции, которая называется average и принимает указатель на массив целых чисел и второй целочисленный аргумент, обозначающий количество элементов в массиве. Согласно этому объявлению, функция возвращает значение типа double. Обратите внимание: возвращаемый тип входит в состав объявления, но редко считается частью сигнатуры функции.
Как видно в листинге 2.1, объявление функции заканчивается точкой с запятой и у него нет тела, заключенного в фигурные скобки. Стоит также отметить, что у параметров нет имен; это корректный синтаксис, но только в объявлениях, а не в определениях. Тем не менее параметры рекомендуется именовать даже при объявлении.
Объявление функции показывает, как ее задействовать, а определение содержит ее реализацию. Чтобы применить функцию, пользователю не обязательно знать имена ее параметров, поэтому в объявлении их можно опустить.
В листинге 2.2 представлено определение функции average, которую мы объявили ранее. Здесь вы можете видеть код, из которого состоит логика функции; он всегда находится в теле, заключенном в фигурные скобки.
Листинг 2.2. Определение функции average
double average(int* array, int length) {
if (length <= 0) {
return 0;
}
double sum = 0.0;
for (int i = 0; i < length; i++) {
sum += array[i];
}
return sum / length;
}
Мы уже об этом говорили, но я подчеркну еще раз: объявление функции хранится в заголовочном файле, а определение (или тело) — в исходном. Нарушать данное правило можно лишь в редких случаях. Кроме того, чтобы иметь доступ к объявлению, исходник должен подключить заголовочный файл. Именно так это работает в C и C++.
Если вы не до конца понимаете, зачем это нужно, то не волнуйтесь: по ходу чтения ясности прибавится.
Наличие у объявления нескольких определений в единице трансляции приведет к ошибке компиляции. Это касается всех функций, структур и глобальных переменных. Следовательно, вы не можете предоставить два определения для одной и той же функции.
Чтобы продолжить наше обсуждение, обратимся к первому примеру в этой главе, который должен продемонстрировать, как правильно скомпилировать проект на C/C++, состоящий из нескольких исходных файлов.
Пример исходных файлов
Пример 2.1 состоит из трех файлов: одного заголовка и двух исходников. Все они находятся в одном каталоге. Представленный в примере код пытается вычислить среднее значение для массива из пяти элементов.
Заголовочный файл играет роль моста, связующего два исходных файла и позволяющего разделить код на две части, которые собираются вместе. Без заголовка код нельзя разделить на два файла, не нарушая описанное выше правило (одни исходники не должны подключаться к другим). Показанный здесь заголовочный файл содержит все, что необходимо одному исходнику для использования функциональности другого.
В заголовочном файле находится объявление всего одной функции, avg, необходимой для работы программы. Один из исходных файлов содержит определение этой функции, а другой — функцию main, которая является точкой входа в программу. Без main нельзя получить исполняемый двоичный файл для запуска программы. Эта функция интерпретируется компиляторами как место, с которого нужно начинать выполнение.
Теперь перейдем к содержимому этих файлов. В листинге 2.3 показан заголовок с перечислением и объявлением функции avg.
Листинг 2.3. Заголовочный файл из примера 2.1 (ExtremeC_examples_chapter2_1.h)
#ifndef EXTREMEC_EXAMPLES_CHAPTER_2_1_H
#define EXTREMEC_EXAMPLES_CHAPTER_2_1_Htypedef enum {
NONE,
NORMAL,
SQUARED
} average_type_t;
// объявление функции
double avg(int*, int, average_type_t);
#endif
Здесь мы можем видеть перечисление — набор именованных целочисленных констант. В языке C у перечислений не может быть отдельных объявлений и определений: они должны объявляться и определяться в одном и том же месте.
Помимо перечисления, в этом листинге можно видеть предварительное объявление функции avg. Предварительным называется объявление, которое находится перед соответствующим определением. Здесь также применяется предотвращение дублирования, которое не дает компилятору подключить заголовочный файл дважды.
В листинге 2.4 показан исходный файл с определением функции avg.
Листинг 2.4. Исходный файл с определением функции avg (ExtremeC_examples_chapter2_1.c)
#include "ExtremeC_examples_chapter2_1.h"
double avg(int* array, int length, average_type_t type) {
if (length <= 0 || type == NONE) {
return 0;
}
double sum = 0.0;
for (int i = 0; i < length; i++) {
if (type == NORMAL) {
sum += array[i];
} else if (type == SQUARED) {
sum += array[i] * array[i];
}
}
return sum / length;
}
Вы уже могли заметить, что имя файла заканчивается на .c. Исходный файл подключает ранее представленный заголовок, поскольку перед использованием перечисления average_type_t и функции avg ему нужны их объявления. Применение нового типа (в данном случае перечисления average_type_t) без его предварительного объявления приводит к ошибке компиляции.
Взгляните на листинг 2.5 со вторым исходным файлом, который содержит функцию main.
Листинг 2.5. Главная функция в примере 2.1 (ExtremeC_examples_chapter2_1_main.c)
#include <stdio.h>
#include "ExtremeC_examples_chapter2_1.h"
int main(int argc, char** argv) {
// объявление массива
int array[5];
// заполнение массива
array[0] = 10;
array[1] = 3;
array[2] = 5;
array[3] = -8;
array[4] = 9;
// вычисление среднего значения с помощью функции avg
double average = avg(array, 5, NORMAL);
printf("The average: %f\n", average);
average = avg(array, 5, SQUARED);
printf("The squared average: %f\n", average);
return 0;
}
В любом проекте на языке C функция main служит точкой входа в программу. В приведенном выше листинге она объявляет и заполняет целочисленный массив, а затем вычисляет для него два разных средних значения. Обратите внимание на то, как main вызывает avg.
Сборка примера
Файлы из примера 2.1, с которыми вы познакомились выше, нужно собрать, чтобы получить итоговый исполняемый файл, который можно будет запустить в качестве программы. Сборка проекта на C/C++ требует компиляции его кодовой базы в переносимые объектные файлы (которые еще называют промежуточными) и затем объединения их в конечные продукты, такие как статические библиотеки или исполняемые файлы.
В других языках программирования сборка проходит аналогичным образом, только промежуточные и конечные продукты будут иметь другие имена и, скорее всего, другие форматы. Например, в Java промежуточными продуктами выступают class-файлы с байт-кодом, а конечными — JAR- или WAR-файлы.
Для компиляции представленных здесь исходников мы не будем использовать интегрированную среду разработки (Integrated Development Environment, IDE). Вместо этого мы применим компилятор напрямую, без вспомогательного ПО. Описанные здесь шаги ничем не отличаются от тех, которые фоново выполняет IDE, компилируя набор исходных файлов.
Прежде чем продолжать, необходимо отметить два важных правила, о которых нужно помнить.
Правило 1: компилируются только исходные файлы. Заголовки не должны содержать ничего, кроме объявлений. Поэтому для сборки примера 2.1 нам нужно всего два исходных файла: ExtremeC_examples_chapter2_1.c и ExtremeC_examples_chapter2_1_main.c.
Правило 2: каждый исходный файл компилируется по отдельности. В контексте примера 2.1 это означает, что компилятор следует запустить два раза, по одному для каждого исходника.
