Существенные особенности C и окружающие их концепции играют ключевую роль в экосистеме Unix, и они привели к тому, что C стал важной и влиятельной технологией, несмотря на ее устаревший и суровый синтаксис. Подробнее о взаимном влиянии C и Unix друг на друга мы поговорим в следующих статьях. А пока давайте начнем эту первую статью с обсуждения директив препроцессора.
Прежде чем читать эту статью, имейте в виду, что вы уже должны быть знакомы с C. Большинство примеров в этой статье тривиальны, но настоятельно рекомендуется что вы знаете синтаксис C, прежде чем переходить к другим статьям. Для вашего удобства ниже приведен список тем, с которыми вам следует ознакомиться, прежде чем приступить к изучению этой книги:
Общие сведения об архитектуре компьютера — вы должны знать о памяти, ЦП, периферийных устройствах и их характеристиках, а также о том, как программа взаимодействует с этими элементами в компьютерной системе.
Общие знания по программированию — вы должны знать, что такое алгоритм, как можно отследить его выполнение, что такое исходный код, что такое двоичные числа и как их связанная арифметика работы.
Знакомство с использованием Терминала и основных команд оболочки в Unix-подобных операционных системах, таких как Linux или macOS.
Промежуточные знания по темам программирования, таким как условные операторы, различные виды циклов, структур или классы как минимум на одном языке программирования, указатели на C или C++, функции и т. д.
Базовые знания об объектно-ориентированном программировании — это не обязательно, поскольку мы подробно объясним объектно-ориентированное программирование, но такое знание поможет вам лучше разобраться при чтении статей в третьей части книги; Ориентация объекта.
Директивы препроцессора

Предварительная обработка — мощная функция C. Мы подробно рассмотрим ее в следующей статье «Компиляция и компоновка», а сейчас давайте определим предварительную обработку как то, что позволяет вам разрабатывать и изменять исходный код перед его отправкой компилятору. Это означает, что конвейер компиляции C как минимум на один шаг больше по сравнению с другими языками. В других языках программирования исходный код напрямую отправляется компилятору, но в C и C++ его следует предварительно обработать.
Этот дополнительный шаг сделал C (и C++) уникальным языком программирования в том смысле, что Программист C может эффективно изменить свой исходный код перед отправкой его компилятору. Эта функция отсутствует в большинстве языков программирования более высокого уровня.
Цель предварительной обработки — удалить директивы предварительной обработки и заменить их эквивалентным сгенерированным кодом C и подготовить окончательный исходный код, готовый для отправки компилятору.
Поведением препроцессора C можно управлять и влиять на него с помощью набора директив. Директивы C — это строки кода, начинающиеся с символа # как в заголовочных, так и в исходных файлах. Эти строки имеют значение только для препроцессора C и никогда для компилятора C. В C есть различные директивы, но некоторые из них очень важны, особенно директивы, используемые для определения макросов, и директивы, используемые для условной компиляции.
В следующем разделе мы объясним макросы и приведем различные примеры, демонстрирующие их различные использует. Мы также анализируем их, чтобы найти их преимущества и недостатки.
Макросы

Ходит много слухов о макросах C. Один говорит, что они делают ваш исходный код слишком сложным и менее читаемым. Другой говорит, что вы сталкиваетесь с проблемами при отладке приложений, если вы использовали макросы в своем коде. Возможно, вы сами слышали некоторые из этих утверждений. Но насколько они действительны? Являются ли макросы злом, которого следует избегать? Или у них есть какие-то преимущества, которые можно привнести в ваш проект?
Реальность такова, что вы найдете макросы в любом известном проекте C. В качестве доказательства загрузите известный проект C, такой как Apache HTTP Server, и выполните grep для #define. Вы увидите список файлов, в которых определены макросы. Для вас, как разработчика C, нет возможности избежать макросов. Даже если вы сами их не используете, вы, скорее всего, увидите их в чужом коде. Поэтому вам нужно узнать, что они собой представляют и как с ними работать.
Команда grep относится к стандартной служебной программе оболочки в Unix-подобных операционных системах, которая ищет шаблон в потоке символов. Его можно использовать для поиска текста или шаблона в содержимом всех файлов, найденных по заданному пути.
Макросы имеют ряд применений, и вы можете увидеть некоторые из них следующим образом:
Определение константа
Использование в качестве функции вместо написания функции на C
Развертка цикла
Защита заголовков
Генерация кода
Условная компиляция

Хотя существует множество других возможных применений макросов, мы сосредоточимся на них в следующих разделах.
ОПРЕДЕЛЕНИЕ МАКРОСА

Давайте начнем обсуждение с небольшого разговора о дизайне программного обеспечения. Определение макросов и их объединение — это искусство, а иногда и захватывающее! Вы начинаете создавать ожидаемый предварительно обработанный код в уме еще до того, как определены какие-либо макросы, и на их основе вы определяете свои макросы. Поскольку это простой способ воспроизвести код и поиграть с ним, им можно злоупотреблять. Чрезмерное использование макросов может не быть большой проблемой для вас, но может быть для ваших товарищей по команде. Но почему?
У макросов есть важная характеристика. Если вы напишите что-то в макросах, они будут заменены другими строками кода перед фазой компиляции, и, наконец, у вас будет плоский длинный кусок кода без какой-либо модульности во время компиляции. Конечно, у вас есть модульность в голове и, вероятно, в ваших макросах, но ее нет в ваших окончательных двоичных файлах. Именно здесь использование макросов может вызвать проблемы с дизайном.
Разработчики программного обеспечения пытаются упаковать аналогичные алгоритмы и концепции в несколько управляемых и многократно используемых модулей, но макросы пытаются сделать все линейным и плоским. Таким образом, когда вы используете макросы в качестве логических строительных блоков в дизайне вашего программного обеспечения, информация о них может быть потеряна после этапа предварительной обработки как часть окончательных единиц преобразования. Вот почему архитекторы и дизайнеры используют эмпирическое правило в отношении макросов:
Если макрос может быть написан как функция на языке C, то вместо этого вы должны написать функцию на языке C!

Условная компиляция — еще одна уникальная особенность C. Она позволяет вам иметь различный предварительно обработанный исходный код на основе разных условий. Несмотря на подразумеваемый смысл, компилятор ничего не делает условно, но предварительно обработанный код, который передается компилятору, может отличаться в зависимости от определенных условий. Эти условия оцениваются препроцессором при подготовке предварительно обработанного кода. Существуют различные директивы, способствующие условной компиляции. Их список можно увидеть следующим образом:
#ifdef
#ifndef
#else
#elif
#endif

Следующий пример (пример 1.7) демонстрирует очень простое использование этих директив:
#define CONDITION
int main(int argc, char** argv) {
#ifdef CONDITION
int i = 0;
i++;
#endif
int j= 0;
return 0;
}
Блок кода 1–14: пример условной компиляции
При предварительной обработке предыдущего кода препроцессор видит определение макроса CONDITION и помечает его как определенное. Обратите внимание, что для макроса CONDITION не предлагается никакого значения, и это абсолютно допустимо. Затем препроцессор идет дальше, пока не достигнет оператора #ifdef. Поскольку макрос CONDITION уже определен, все строки между #ifdef и #endif будут скопированы в окончательный исходный код.
Вы можете увидеть предварительно обработанный код в следующем поле кода:
int main(int argc , char** argv) {
int i = 0;
i++;
int j= 0;
return 0;
}
Code Box 1 –15: Пример 1.7 после этапа предварительной обработки
Если бы макрос не был определен, мы бы не увидели никакой замены директив #if-#endif. Таким образом, предварительно обработанный код может выглядеть примерно так:
int main(int argc, char** argv) {
int j= 0;
return 0;
}< br /> Блок кода 1–16: пример 1.7 после этапа предварительной обработки, предполагающий, что макрос CONDITION не определен
Обратите внимание на пустые строки в обоих блоках кода 1–15 и 1–16, которые остались с этапа предварительной обработки , после замены раздела #ifdef-#endif его оценочным значением.
Макросы можно определить с помощью параметров -D, переданных команде компиляции. Что касается предыдущего примера, мы можем определить макрос CONDITION следующим образом:
$ gcc -DCONDITION -E main.c

Концепция указателя на переменную, или сокращенно указатель, является одной из самых фундаментальных концепций в C. В большинстве языков программирования высокого уровня вы вряд ли найдете какой-либо прямой признак. Фактически их заменили некоторые понятия-близнецы, например ссылки в Java. Стоит отметить, что указатели уникальны в том смысле, что адреса, на которые они указывают, могут напрямую использоваться аппаратным обеспечением, но это не относится к концепциям близнецов более высокого уровня, таким как ссылки.
Глубокое понимание указателей и то, как они работают, имеет решающее значение для того, чтобы стать опытным программистом на C. Они являются одним из самых фундаментальных понятий в управлении памятью, и, несмотря на их простой синтаксис, они могут привести к катастрофе при неправильном использовании. Мы рассмотрим темы, связанные с управлением памятью, в статье 4 «Структура памяти процесса» и статье 5 «Стек и куча», но здесь, в этой статье, мы хотим повторить все, что касается указателей. Если вы уверены в базовой терминологии и понятиях, связанных с указателями, можете пропустить этот раздел.
Синтаксис

Если вы будете искать размер указателя в C в Google, вы можете понять, что не можете найти окончательный ответ на этот вопрос. Есть много ответов, и это правда, что вы не можете определить фиксированный размер для указателя в разных архитектурах. Размер указателя зависит от архитектуры, а не от конкретной концепции C. C не слишком беспокоится о таких деталях, связанных с аппаратным обеспечением, и пытается предоставить общий способ работы с указателями и другими концепциями программирования. Вот почему мы знаем C как стандарт. Для C важны только указатели и арифметические операции с ними.
Архитектура относится к оборудованию, используемому в компьютерной системе. Более подробную информацию вы найдете в следующей статье «Компиляция и компоновка».
Вы всегда можете использовать функцию sizeof для получения размера указателя. Достаточно увидеть результат sizeof(char*) на вашей целевой архитектуре. Как правило, указатели имеют размер 4 байта в 32-разрядных архитектурах и 8 байтов в 64-разрядных архитектурах, но в других архитектурах вы можете встретить другие размеры. Имейте в виду, что код, который вы пишете, не должен зависеть от конкретного значения размера указателя и не должен делать никаких предположений об этом. В противном случае у вас возникнут проблемы при переносе кода на другие архитектуры.
Висячие указатели

C — процедурный язык программирования. В C функции действуют как процедуры и являются строительными блоками программы на C. Итак, важно знать, что они из себя представляют, как ведут себя и что происходит, когда вы входите в функцию или выходите из нее. В общем, функции (или процедуры) аналогичны обычным переменным, которые хранят алгоритмы вместо значений. Объединяя переменные и функции в новый тип, мы можем хранить соответствующие значения и алгоритмы в одной и той же концепции. Это то, что мы делаем в объектно-ориентированном программировании, и это будет рассмотрено в третьей части книги «Ориентация объекта». В этом разделе мы хотим изучить функции и обсудить их свойства в C.
Анатомия функции

В этом разделе мы хотим собрать все о функциях C в одном месте. Если вы чувствуете, что это вам знакомо, вы можете просто пропустить этот раздел.
Функция — это блок логики, который имеет имя, список входных параметров и список выходных результатов. В C и многих других языках программирования, на которые повлиял C, функции возвращают только одно значение. В объектно-ориентированных языках, таких как C++ и Java, функции (которые обычно называются методами) также могут генерировать исключение, чего нельзя сказать о C. Функции вызываются вызовом функции, который просто использует имя функции. для выполнения его логики. Правильный вызов функции должен передать все необходимые аргументы функции и дождаться ее выполнения. Обратите внимание, что в C функции всегда блокируются. Это означает, что вызывающая сторона должна дождаться завершения вызываемой функции и только после этого может получить возвращенный результат.
В отличие от блокирующей функции, у нас может быть неблокирующая функция. функция. При вызове неблокирующей функции вызывающая сторона не ждет завершения функции и может продолжить ее выполнение. В этой схеме обычно есть механизм обратного вызова, который срабатывает, когда вызываемая (или вызываемая) функция завершается. Неблокирующая функция также может называться асинхронной функцией или просто асинхронной функцией. Поскольку у нас нет асинхронных функций в C, нам нужно реализовать их с помощью многопоточных решений. Мы объясним эти концепции более подробно в пятой части книги, Параллелизм.
Интересно добавить, что в настоящее время растет интерес к использованию неблокирующих функций вместо блокирующих. Обычно его называют событийно-ориентированным программированием. Неблокирующие функции занимают центральное место в этом подходе к программированию, и большинство написанных функций являются неблокирующими.
В событийно-ориентированном программировании фактические вызовы функций происходят внутри цикла обработки событий, а соответствующие обратные вызовы запускаются при возникновении события. мероприятие. Фреймворки, такие как libuv и libev, продвигают этот способ кодирования и позволяют разрабатывать программное обеспечение на основе одного или нескольких циклов событий.
Важность в разработке

Функции являются фундаментальными строительными блоками процедурного программирования. С момента их официальной поддержки в языках программирования они оказали огромное влияние на то, как мы пишем код. Используя функции, мы можем хранить логику в полупеременных сущностях и вызывать их всякий раз, когда и где бы они ни потребовались. Используя их, мы можем написать конкретную логику только один раз и использовать ее многократно в разных местах.
Кроме того, функции позволяют нам скрыть часть логики от другой существующей логики. Другими словами, они вводят уровень абстракции между различными логическими компонентами. В качестве примера предположим, что у вас есть функция avg, которая вычисляет среднее значение входного массива. И у вас есть другая функция, main, которая вызывает функцию avg. Мы говорим, что логика внутри функции avg скрыта от логики внутри функции main.
Поэтому, если вы хотите изменить логику внутри avg, вам не нужно менять логику внутри функции main. Это потому, что функция main зависит только от имени и доступности функции avg. Это большое достижение, по крайней мере, для тех лет, когда нам приходилось использовать перфокарты для написания и выполнения программ!
Мы до сих пор используем эту возможность при проектировании библиотек, написанных на C или даже на языках программирования более высокого уровня, таких как C++. и Java.
Управление стеком

Если вы посмотрите на структуру памяти процесса, работающего в Unix-подобной операционной системе, вы заметите, что все процессы имеют схожую структуру. Мы обсудим этот макет более подробно в статье 4 «Структура памяти процесса», а сейчас мы хотим представить один из его сегментов; сегмент стека. Сегмент стека — это место в памяти по умолчанию, из которого выделяются все локальные переменные, массивы и структуры. Таким образом, когда вы объявляете локальную переменную в функции, она выделяется из сегмента стека. Это размещение всегда происходит поверх сегмента стека.
Обратите внимание на термин стек в названии сегмента. Это означает, что этот сегмент ведет себя как стек. Переменные и массивы всегда размещаются поверх него, и те, что находятся наверху, удаляются первыми. Помните эту аналогию с концепцией стека. Мы вернемся к этому в следующем абзаце.
Сегмент стека также используется для вызовов функций. Когда вы вызываете функцию, кадр стека, содержащий адрес возврата и все передаваемые аргументы, помещается поверх сегмента стека, и только после этого выполняется логика функции. При возврате из функции кадр стека выскакивает, и выполняется инструкция, адресованная по адресу возврата, которая обычно должна продолжать вызывающую функцию.
Все локальные переменные, объявленные в теле функции, помещаются поверх Сегмент стека. Итак, при выходе из функции все переменные Stack освобождаются. Вот почему мы называем их локальными переменными, и именно поэтому функция не может получить доступ к переменным в другой функции. Этот механизм также объясняет, почему локальные переменные не определяются перед входом в функцию и после выхода из нее.
Понимание сегмента стека и того, как он работает, имеет решающее значение для написания правильного и осмысленного кода. Это также предотвращает возникновение распространенных ошибок памяти. Это также напоминание о том, что вы не можете создавать переменные в стеке с любым размером, который вам нравится. Стек — это ограниченная часть памяти, и вы можете заполнить его и потенциально получить ошибку переполнения стека. Обычно это происходит, когда у нас слишком много вызовов функций, которые занимают весь сегмент стека своими кадрами стека. Это очень распространено при работе с рекурсивными функциями, когда функция вызывает сама себя без каких-либо условий прерывания или ограничений.
Передача по значению против передачи по ссылке

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

Вы знаете, что в каждом языке программирования есть несколько примитивных типов данных (PDT). Используя эти PDT, вы можете проектировать свои структуры данных и писать на их основе свои алгоритмы. Эти PDT являются частью языка программирования, и их нельзя изменить или удалить. Например, вы не можете иметь C без примитивных типов, int и double.
Структуры вступают в игру, когда вам нужно иметь свои собственные определенные типы данных, а типов данных в языке недостаточно. Определяемые пользователем типы (UDT) — это типы, созданные пользователем и не являющиеся частью языка.
Обратите внимание, что UDT отличаются от типов, которые вы можете определить с помощью typedef. Ключевое слово typedef на самом деле не создает новый тип, а скорее определяет псевдоним или синоним для уже определенного типа. Но структуры позволяют вводить в программу совершенно новые UDT.
В других языках программирования структуры имеют схожие понятия, например, классы в C++ и Java или пакеты в Perl. Они считаются создателями шрифтов в этих языках.
Почему пользовательские типы?

Структуры инкапсулируют связанные значения в единый унифицированный тип. В качестве первого примера мы можем сгруппировать красные, зеленые и синие переменные под новым единым типом данных, называемым color_t. Новый тип, color_t, может представлять цвет RGB в различных программах, таких как приложение для редактирования изображений. Мы можем определить соответствующую структуру C следующим образом:
struct color_t {
int red;
int green;
int blue;
};
Code Box. 1–33: Структура в C, представляющая цвет RGB
Как мы уже говорили ранее, структуры выполняют инкапсуляцию. Инкапсуляция — одна из самых фундаментальных концепций в разработке программного обеспечения. Речь идет о группировке и инкапсуляции связанных полей под новым типом. Затем мы можем использовать этот новый тип для определения необходимых переменных. Мы подробно опишем инкапсуляцию в статье 6, ООП и инкапсуляция, а также поговорим об объектно-ориентированном проектировании.
Обратите внимание, что мы используем суффикс _t для именования новых типов данных.
Структура памяти

Как мы объяснили в предыдущих разделах, в целом у нас есть два типа типов данных в C. Есть типы, примитивные для языка, и есть типы, которые определяются программистами с помощью ключевого слова struct. Первые типы – это PDT, а вторые – UDT.
До сих пор наши примеры структур относились к UDT (структурам), состоящим только из PDT. Но в этом разделе мы собираемся привести пример UDT (структур), которые сделаны из других UDT (структур). Это так называемые сложные типы данных, которые являются результатом вложения нескольких структур.
Начнем с примера 1.23:
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;
Блок кода 1–36 [ExtremeC_examples_article1_23.c]: объявление некоторых вложенных структур
В предыдущем блоке кода у нас есть три структуры; point_t, circle_t и line_t. Структура point_t является простым UDT, поскольку состоит только из PDT, но другие структуры содержат переменную типа point_t, что делает их сложными UDT.
Размер сложной структуры вычисляется точно так же, как размер простая структура, путем суммирования размеров всех его полей. Конечно, мы все еще должны быть осторожны с выравниванием, потому что оно может повлиять на размер сложной структуры. Таким образом, sizeof(point_t) будет равен 8 байтам, если sizeof(int) равен 4 байтам. Затем sizeof(circle_t) равен 12 байтам, а sizeof(line_t) равен 16 байтам.
Общепринято вызывать объекты структурных переменных. Они в точности аналогичны объектам в объектно-ориентированном программировании, и мы увидим, что они могут инкапсулировать как значения, так и функции. Таким образом, совсем не неправильно называть их C-объектами.
Указатели структур

Подобно указателям на PDT, у нас также могут быть указатели на UDT. Они работают точно так же, как указатели PDT. Они указывают на адрес в памяти, и вы можете выполнять с ними арифметические операции точно так же, как и с указателями PDT. Указатели UDT также имеют размер арифметического шага, эквивалентный размеру UDT. Если вы ничего не знаете об указателях или разрешенных арифметических операциях над ними, перейдите в раздел Указатели и прочтите его.
Важно знать, что структурная переменная указывает на адрес первого поле структурной переменной. В предыдущем примере, примере 1.23, указатель типа point_t указывал бы на адрес своего первого поля x. Это также верно для типа, circle_t. Указатель типа circle_t будет указывать на свое первое поле, center, и, поскольку на самом деле это объект point_t, он будет указывать на адрес первого поля, x, в типе point_t. Следовательно, у нас может быть 3 разных указателя на одну и ту же ячейку в памяти. Следующий код продемонстрирует это:
#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 =
point_t* p2 = (point_t*)
int* p3 = (int*)
printf("p1: %p\n", (void*)p1) ;
printf("p2: %p\n", (void*)p2);
printf("p3: %p\n", (void*)p3);
return 0;
}
Блок кода 1–37 [ExtremeC_examples_article1_24.c]: наличие трех разных указателей из трех разных типов, адресующих один и тот же байт в памяти
И вот результат:
$ clang ExtremeC_examples_article1_24.c
$ ./a.out
p1: 0x7ffee846c8e0
p2: 0x7ffee846c8e0
p3: 0x7ffee846c8e0
$
Shell Box 1–15 : Вывод примера 1.24
Как видите, все указатели обращаются к одному и тому же байту, но их типы разные. Обычно это используется для расширения структур, поступающих из других библиотек, путем добавления дополнительных полей. Точно так же мы реализуем наследование в C. Мы обсудим это в статье 8 «Наследование и полиморфизм».
Это был последний раздел этой статьи. В следующей статье мы углубимся в конвейер компиляции C и как правильно скомпилировать и связать проект C.
Резюме

В этой статье мы еще раз рассмотрели некоторые важные особенности языка программирования C. Мы попытались пойти дальше и показать аспекты дизайна этих функций и концепции, лежащие в их основе. Конечно, правильное использование функции требует более глубокого понимания различных аспектов этой функции. В рамках этой статьи мы обсудили следующее:
Мы говорили о фазе предварительной обработки C и о том, как различные директивы могут повлиять на то, чтобы препроцессор действовал по-другому или сгенерировал для нас определенный код C.
Макросы и макрос Механизм расширения позволяет нам генерировать код C перед передачей единицы трансляции на этап компиляции.
Условные директивы позволяют нам изменять предварительно обработанный код на основе определенных условий и позволяют нам иметь разный код для разных ситуаций.
Мы также рассмотрели указатели на переменные и то, как они используются в C.
Мы представили универсальные указатели и то, как мы можем иметь функцию, которая принимает любой тип указателя.
Мы обсудили некоторые проблемы, такие как ошибки сегментации и висячие указатели, чтобы показать несколько катастрофических ситуаций, которые могут возникнуть из-за неправильного использования указателей.
Далее мы обсудили функции и рассмотрели их синтаксис.
Процедурная программа на C.
Мы также объяснили механизм вызова функции и то, как аргументы передаются в функцию с помощью кадров стека.
В этой статье были рассмотрены указатели на функции. Мощный синтаксис указателей на функции позволяет нам хранить логику в объектах, подобных переменным, и использовать их позже. На самом деле они являются фундаментальным механизмом, который сегодня используется каждой отдельной программой для загрузки и работы.
Структуры вместе с указателями на функции привели к инкапсуляции в C. Мы поговорим об этом подробнее в третьей части книги, Ориентация на объекты.
Мы попытались объяснить аспекты проектирования структур и их влияние на то, как мы разрабатываем программы на языке C.
Мы также обсудили размещение структурных переменных в памяти и то, как они размещаются внутри памяти, чтобы максимизировать Загрузка ЦП.
Также обсуждались вложенные структуры. Мы также заглянули внутрь сложных структурных переменных и обсудили, как должно выглядеть их расположение в памяти.
В заключительном разделе этой статьи мы говорили об указателях структур.

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

Глубокое погружение в C: Статья № 1 : Основные возможности C.

Глубокое погружение в C — это статья, которая предоставит вам как фундаментальные, так и дополнительные знания, необходимые для разработки и поддержки реальных приложений на языке C. Как правило, одного знания синтаксиса языка программирования недостаточно для написания на нем успешных программ — и это имеет большее значение для C по сравнению с большинством других языков. Итак, мы рассмотрим все концепции, необходимые для написания отличного программного обеспечения на C, от простых программ с одним процессом до более сложных систем с несколькими процессами.
Эта первая статья в первую очередь посвящена конкретным функциям C, которые вы найдете чрезвычайно полезным при написании программ на C. Эти функции связаны с ситуациями, с которыми вы будете регулярно сталкиваться при написании на C. Хотя существует ряд отличных книг и учебных пособий по программированию на C, которые объясняют все подробно и охватывают почти все аспекты синтаксиса C, было бы полезно рассмотреть некоторые ключевые функции здесь, прежде чем мы углубимся в C.
Эти функции включают директивы препроцессора, указатели переменных, указатели функций и структуры. Конечно, они распространены в более современных языках программирования, и их аналоги легко найти в Java, C#, Python и т. д. Например, ссылки в Java можно рассматривать как элементы, аналогичные указателям на переменные в C. Эти функции и связанные с ними концепции настолько фундаментальны, что без них никакая часть программного обеспечения не сможет продолжать работать, даже если она может быть выполнена! Даже простая программа «Hello World» не может работать без загрузки ряда разделяемых библиотек, требующих использования указателей на функции!
Итак, всякий раз, когда вы видите что-то вроде светофора, центрального компьютера вашего автомобиля, микроволновой печи на кухне , операционная система вашего смартфона или, возможно, любое другое устройство, о котором вы обычно не думаете, все они содержат части программного обеспечения, написанные на C.
На нашу сегодняшнюю жизнь сильно повлияло изобретение программирования C языке, и наш мир был бы совсем другим без C.
В этой статье основное внимание уделяется основным функциям и механизмам, необходимым для написания экспертного кода на C, и содержится тщательно подобранный набор функций, которые мы должны подробно изучить. Мы рассмотрим следующие темы:
Директивы препроцессора, макросы и условная компиляция. Предварительная обработка — это одна из тех функций C, которые не так просто найти в других языках программирования. Предварительная обработка дает много преимуществ, и мы углубимся в некоторые из ее интересных применений, включая макросы и условные директивы.
Указатели на переменные. В этом разделе подробно рассматриваются указатели на переменные и их использование. Мы также найдем полезную информацию, рассмотрев некоторые недостатки, которые могут быть вызваны неправильным использованием указателей на переменные.
Функции. Этот раздел статьи представляет собой глубокое погружение во все, что мы знаем о функциях, помимо их синтаксиса. . На самом деле, синтаксис — это самая простая часть! В этом разделе мы рассмотрим функции как строительные блоки для написания процедурного кода. В этом разделе также рассказывается о механизме вызова функции и о том, как функция получает свои аргументы от вызывающей функции.
Указатели на функции. Несомненно, указатели на функции — одна из наиболее важных особенностей языка C. Указатель на функцию — это указатель, указывающий на к существующей функции вместо переменной. Возможность хранить указатель на существующую логику чрезвычайно важна при разработке алгоритмов, и поэтому у нас есть специальный раздел, посвященный этой теме. Указатели на функции используются в самых разных приложениях, начиная от загрузки динамических библиотек и заканчивая полиморфизмом, и в следующих нескольких статьях мы увидим гораздо больше указателей на функции.
Структуры. Структуры C могут иметь простой синтаксис и передавать простая идея, но они являются основными строительными блоками для написания хорошо организованного и более объектно-ориентированного кода. Их важность вместе с указателями на функции просто невозможно переоценить! В последнем разделе этой статьи мы еще раз вернемся ко всему, что вам нужно знать о структурах в C и о приемах, связанных с ними.

Макросы определяются с помощью директивы #define. Каждый макрос имеет имя и возможный список параметров. У него также есть значение, которое заменяется его именем на этапе предварительной обработки посредством шага, называемого расширением макроса. Макрос также может быть неопределенным с помощью директивы #undef. Начнем с простого примера, пример 1.1:
#define ABC 5
int main(int argc, char** argv) {
int x = 2;
int y = ABC;
int z = x + y;
return 0;
}
Поле кода 1–1: определение макроса
В предыдущем поле кода ABC не переменная, которая содержит целочисленное значение, и не целочисленная константа. На самом деле это макрос с именем ABC, и его соответствующее значение равно 5. После фазы раскрытия макроса результирующий код, который можно передать компилятору C, выглядит примерно так, как мы видим:
int main(int argc, char** argv) {
int x = 2;
int y = 5;
int z = x + y;
return 0;
}< br /> Поле кода 1–2: сгенерированный код для примера 1.1 после фазы расширения макроса
Код в поле кода 1–2 имеет допустимый синтаксис C, и теперь компилятор может продолжить и скомпилировать его. В предыдущем примере препроцессор выполнял раскрытие макроса, и как его часть препроцессор просто заменял имя макроса его значением. Препроцессор также удалил комментарии в начальных строках.
Давайте теперь посмотрим на другой пример, пример 1.2:
#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–3: определение макроса, подобного функции
В предыдущем блоке кода, похожем на пример 1.1, ADD не является функцией. Это просто макрос, похожий на функцию, который принимает аргументы. После предобработки результирующий код будет таким:
int main(int argc, char** argv) {
int x = 2;
int y = 3
int z = x + y;
return 0;
}
Блок кода 1–4: пример 1.2 после предварительной обработки и расширения макроса
Как видно из предыдущего блока кода, расширение произошло следующее. Аргумент x, используемый в качестве параметра a, заменяется всеми экземплярами a в значении макроса. То же самое для параметра b и соответствующего ему аргумента y. Затем происходит окончательная подстановка, и мы получаем x + y вместо ADD(a, b) в предварительно обработанном коде.
Поскольку макросы, подобные функциям, могут принимать входные аргументы, они могут имитировать функции C. Другими словами, вместо того, чтобы помещать часто используемую логику в функцию C, вы можете назвать эту логику макросом, подобным функции, и вместо этого использовать этот макрос.
Таким образом, вхождения макросов будут заменены часто используемой логикой в ​​рамках фазы предварительной обработки, и нет необходимости вводить новую функцию C. Мы обсудим это подробнее и сравним два подхода.
Макросы существуют только до этапа компиляции. Это означает, что компилятор теоретически ничего не знает о макросах. Это очень важный момент, который следует помнить, если вы собираетесь использовать макросы вместо функций. Компилятор знает о функции все, потому что она является частью грамматики C, анализируется и хранится в дереве разбора. Но макрос — это всего лишь директива препроцессора C, известная только самому препроцессору.
Макросы позволяют генерировать код перед компиляцией. В других языках программирования, таких как Java, для выполнения этой задачи необходимо использовать генератор кода. Мы приведем примеры, касающиеся такого применения макросов.
Современные компиляторы C знают о директивах препроцессора C. Несмотря на распространенное мнение, что они ничего не знают о фазе предварительной обработки, на самом деле они знают. Современные компиляторы C знают об исходном коде до того, как перейдут к этапу предварительной обработки. Посмотрите на следующий код:
#include ‹stdio.h›
#define CODE \
printf(“%d\n”, i);
int main(int argc , char** argv) {
CODE
return 0;
}
Code Box 1–5 [example.c]: определение макроса, которое приводит к ошибке необъявленного идентификатора
Если вы скомпилируете приведенный выше код с помощью clang в macOS, на выходе будет следующее:
$ clang example.c
code.c:7:3: ошибка: использование необъявленного идентификатора 'i'< br /> CODE
^
code.c:4:16: примечание: расширено из макроса 'CODE'
printf("%d\n", i);
^
Произошла 1 ошибка.
$
Оболочка Box 1–1: выходные данные компиляции ссылаются на определение макроса
Как видите, компилятор сгенерировал сообщение об ошибке, которое указывает точно к строке, в которой определен макрос.
Кстати, в большинстве современных компиляторов вы можете просмотреть результат предварительной обработки непосредственно перед компиляцией. Например, при использовании gcc или clang вы можете использовать параметр -E для вывода кода после предварительной обработки. В следующем окне оболочки показано, как использовать параметр -E. Обратите внимание, что вывод показан не полностью:
$ clang -E example.c
# 1 «sample.c»# 1 «‹встроенный›» 1
# 1 «‹встроенный -in›» 3
# 361 «‹встроенный›» 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;
}
$
Shell Box 1–2: код example.c после фазы предварительной обработки
Теперь мы подошли к важное определение. Единица трансляции (или единица компиляции) — это предварительно обработанный код C, который готов для передачи компилятору.
В единице трансляции все директивы заменяются включениями или расширениями макросов и плоским длинным фрагментом кода C.
Теперь, когда вы знаете больше о макросах, давайте поработаем над некоторыми более сложными примерами. Они покажут вам силу и опасность макросов. На мой взгляд, экстремальная разработка умело справляется с опасными и деликатными вещами, и это как раз то, чем занимается C.
Следующий пример интересен. Просто обратите внимание на последовательность использования макросов для создания цикла:
#include ‹stdio.h›
#define PRINT(a) printf(“%d\n”, a);< br /> #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;
}
Code Box 1–6 : Использование макросы для создания цикла
Как вы видите в предыдущем блоке кода, код внутри функции main никоим образом не является допустимым кодом C! Но после предварительной обработки мы получаем корректный исходный код на C, который без проблем компилируется. Ниже приведен предварительно обработанный результат:

… содержимое stdio.h …

int main(int argc, char** argv) {
for (int counter = 1; counter ‹= 10; counter++) {
printf("%d\n", counter);
}
return 0;
}
Код Вставка 1–7: пример 1.3 после фазы предварительной обработки
В кодовой вставке 1–6 в функции main мы просто использовали другой набор инструкций, не похожий на C, для написания нашего алгоритма. Затем после препроцессинга в Code Box 1–7 мы получили полностью функциональную и правильную программу на C. Это важное применение макросов; для определения нового предметно-ориентированного языка (DSL) и написания кода с его использованием.
DSL очень полезны в разных частях проекта; например, они широко используются в средах тестирования, таких как платформа Google Test (gtest), где DSL используется для написания утверждений, ожиданий и тестовых сценариев.
Следует отметить, что у нас нет никаких директив C в окончательный предварительно обработанный код. Это означает, что директива #include в Code Box 1–6 была заменена содержимым файла, на который она ссылалась. Вот почему вы видите содержимое заголовочного файла stdio.h (который мы заменили многоточием) в полях кода 1–7 перед основной функцией.
Давайте теперь посмотрим на следующий пример, пример 1.4, в котором представлены два новые операторы относительно параметров макроса; операторы # и ##:
#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;
}
Блок кода 1–8: использование операторов # и ## в макросе
При развертывании макроса оператор # превращает параметр в строковую форму, заключенную в пару кавычек. Например, в предыдущем коде оператор #, использованный перед параметром NAME, превращает его в «копию» в предварительно обработанном коде.
Оператор ## имеет другое значение. Он просто объединяет параметры с другими элементами в определении макроса и обычно формирует имена переменных. Ниже приведен окончательный предварительно обработанный исходный код для примера 1.4:

… содержимое stdio.h …

… содержимое string.h …

int main(int argc, char** argv) {
char copy_cmd[256] = «»; strcpy(copy_cmd, «копировать»);
char paste_cmd[256] = «»; strcpy(paste_cmd, «вставить»);
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) {
}< br /> return 0;
}
Блок кода 1–9: пример 1.4 после этапа предварительной обработки
Сравнение исходного кода до и после предварительной обработки помогает понять, как операторы # и ## применяются к аргументы макроса. Обратите внимание, что в окончательном предварительно обработанном коде все строки, расширенные из одного и того же определения макроса, находятся на одной строке.
Рекомендуется разбивать длинные макросы на несколько строк, но не забывайте использовать \ (один обратный слеш). чтобы сообщить препроцессору, что остальная часть определения находится на следующей строке. Обратите внимание, что \ не заменяется символом новой строки. Вместо этого это индикатор того, что следующая строка является продолжением того же определения макроса.
Теперь давайте поговорим о другом типе макросов. В следующем разделе мы поговорим о макросах с переменным числом аргументов, которые могут принимать переменное количество аргументов.
МАКРОСЫ С ПЕРЕМЕННЫМИ УСЛОВИЯМИ

Следующий пример, пример 1.5, посвящен вариативным макросам, которые могут принимать переменное количество входных аргументов. Иногда один и тот же макрос с переменным числом аргументов принимает 2 аргумента, иногда 4 аргумента, а иногда и 7. Макросы с переменным числом аргументов очень удобны, когда вы не уверены в количестве аргументов при различных применениях одного и того же макроса. Вот простой пример:
#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 («Недопустимое количество аргументов для версии %s\n», VERSION);
exit(1);
}
if (strcmp( argv[1], «-n») != 0) {
LOG_ERROR («%s — неверный параметр по индексу %d для версии %s», argv[1], 1, VERSION);< br /> exit(1);
}
// …
return 0;
}
Code Box 1–10: определение и использование вариативного макроса< br /> В предыдущем поле кода вы видите новый идентификатор: __VA_ARGS__. Это индикатор, который указывает препроцессору заменить его всеми оставшимися входными аргументами, которые еще не назначены ни одному параметру.
В предыдущем примере при втором использовании LOG_ERROR, согласно определению макроса, аргументы argv[1], 1 и VERSION — это те входные аргументы, которые не назначены никакому параметру. Таким образом, они будут использоваться вместо __VA_ARGS__ при расширении макроса.
Кстати, функция fprintf записывает данные в дескриптор файла. В примере 1.5 дескриптор файла — stderr, который является потоком ошибок процесса. Также обратите внимание на конечную точку с запятой после каждого использования LOG_ERROR. Это обязательно, потому что макрос не предоставляет их как часть своего определения, и программист должен добавить эту точку с запятой, чтобы сделать окончательный предварительно обработанный код синтаксически правильным.
Следующий код является окончательным выводом после прохождения через препроцессор C:

… содержимое stdio.h …

… содержимое stdlib.h …

… содержимое string.h …

int main(int argc, char** argv) {
if (argc ‹ 3) {
fprintf(stderr, «Недопустимое количество аргументов для версии %s\n. ", "2.3.4");
exit(1);
}
if (strcmp(argv[1], "-n") != 0) {
fprintf(stderr, «%s — неправильный параметр по индексу %d для версии %s.», argv[1], 1, «2.3.4»);
exit(1);
}
// …
return 0;
}
Поле кода 1–11: пример 1. 5 после фазы предварительной обработки
Следующий пример, пример 1.6, представляет собой постепенное использование макросов с переменным числом переменных, которые пытаются имитировать цикл. Об этом есть известный пример. Прежде чем использовать foreach в C++, инфраструктура boost предлагала (и до сих пор предлагает) поведение foreach с использованием ряда макросов.
По следующей ссылке вы можете увидеть, как макрос BOOST_FOREACH определяется как последняя вещь в заголовке. файл: «https://www.boost.org/doc/libs/1_35_0/boost/foreach.hpp». Он используется для перебора коллекции boost, и на самом деле это макрос, похожий на функцию.
В следующем примере (пример 1.6) речь идет о простом цикле, который совсем не сравним с foreach в boost, но тем не менее он дает вам представление о том, как использовать макросы с переменным числом переменных для повторения ряда инструкций:
#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( копировать вставить вырезать)
LOOP(копировать, вставить, вырезать)
LOOP(копировать, вставить, вырезать, выбрать)
return 0;
}
Code Box 1– 12. Использование макросов с переменным числом переменных для имитации цикла
Прежде чем приступить к объяснению примера, давайте посмотрим на окончательный код после предварительной обработки. Тогда объяснение произошедшего будет проще:

… содержимое stdio.h …

int main(int argc, char** argv) {
printf("%s\n", "копировать, вставить, вырезать"); printf("%s\n", ""); printf("%s\n", "");
printf("%s\n", "копировать"); printf("%s\n", "вставить"); printf("%s\n", "вырезать");
printf("%s\n", "копировать"); printf("%s\n", "вставить"); printf("%s\n", "cut");
return 0;
}
Блок кода 1–13: пример 1.6 после этапа предварительной обработки
Если вы посмотрите на предварительно обработанный код, вы увидите, что макрос LOOP был расширен до нескольких инструкций printf вместо циклических инструкций, таких как for или while. Понятно, почему это так, и это из-за того, что препроцессор не пишет за нас умный код на C. Именно для того, чтобы заменить макросы инструкциями, данными нами.
Единственный способ создать цикл с помощью макроса — это просто поставить инструкции итерации одну за другой, причем как какие-то отдельные инструкции. Это означает, что простой макроцикл с 1000 итераций будет заменен 1000 инструкциями на языке C, и в конечном коде у нас не будет настоящего цикла C.
Предыдущий метод приведет к большому размеру двоичного файла, что можно считать недостатком. Но размещение инструкций друг за другом вместо помещения их в цикл, известный как развертывание цикла, имеет свои собственные приложения, которые требуют приемлемого уровня производительности в ограниченных и высокопроизводительных средах. Согласно тому, что мы объяснили до сих пор, кажется, что развертывание цикла с использованием макросов — это компромисс между размером двоичного файла и производительностью. Мы поговорим об этом подробнее в следующем разделе.
Есть еще одно замечание по предыдущему примеру. Как видите, различные варианты использования макроса LOOP в функции main дали разные результаты. При первом использовании мы передаем copy paste cut без запятых между словами. Препроцессор принимает его как один вход, поэтому имитируемый цикл имеет только одну итерацию.
Во втором случае входные данные копировать, вставить, вырезать передаются со словами, разделенными запятыми. Теперь препроцессор обрабатывает их как три разных аргумента; следовательно, смоделированный цикл имеет три итерации. Это видно из следующего окна оболочки 1–3.
В третьем использовании мы передаем четыре значения, копируем, вставляем, вырезаем, выбираем, но обрабатываются только три из них. Как видите, предварительно обработанный код точно такой же, как и при втором использовании. Это связано с тем, что наши циклические макросы могут обрабатывать только списки до трех элементов. Дополнительные элементы после третьего игнорируются.
Обратите внимание, что это не приводит к ошибкам компиляции, потому что в окончательном коде C не было сгенерировано ничего неправильного, но наши макросы ограничены в количестве элементов, которые они могут обработать:
> $ gcc exapmle1_6.c
$ ./a.out
копировать вставить вырезать
скопировать
вставить
вырезать
$
Shell Box 1 –3: Компиляция и вывод примера 1.6
ПРЕИМУЩЕСТВА И НЕДОСТАТКИ МАКРОСОВ

Опять же, с точки зрения отладки макросы — это зло. Разработчик использует ошибки компиляции, чтобы найти места, где существуют синтаксические ошибки, в рамках своих ежедневных задач разработки. Они также используют журналы и, возможно, предупреждения компиляции, чтобы обнаружить ошибку и исправить ее. И ошибки компиляции, и предупреждения полезны для процедуры анализа ошибок, и оба они генерируются компиляторами.
Что касается макросов, особенно старых компиляторов C, компилятор ничего не знал о макросах и он рассматривал источник компиляции (единицу перевода) как длинный, линейный, плоский фрагмент кода. Таким образом, для разработчика, который смотрит на реальный код 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 могут иметь преимущества, если влияние их предварительно обработанной формы изучено и известно. Если вы работаете над проектом в команде, всегда делитесь своими решениями относительно использования макросов в команде и следите за решениями, принятыми внутри команды.
Условная компиляция.

Это отличная функция, потому что она позволяет определять макросы из исходных файлов. Это особенно полезно при наличии одного исходного кода, но его компиляции для разных архитектур, например, Linux или macOS, которые имеют разные определения макросов по умолчанию и библиотеки. оператор защиты заголовка. Этот оператор защищает файл заголовка от двойного включения на этапе предварительной обработки, и мы можем сказать, что почти все файлы заголовков C и C++ почти в каждом проекте имеют этот оператор в качестве своей первой инструкции.
Следующий код, пример 1.8, является примером того, как использовать оператор защиты заголовка. Предположим, что это содержимое заголовочного файла, и случайно оно может быть дважды включено в единицу компиляции. Обратите внимание, что пример 1.8 — это всего лишь один заголовочный файл, и его не предполагается компилировать:
#ifndef EXAMPLE_1_8_H
#define EXAMPLE_1_8_H
void say_hello();
int read_age();
#endif
Кодовая вставка 1–17: Пример защиты заголовка
Как видите, все объявления переменных и функций помещаются в пару #ifndef и #endif и защищены от множественное включение макросом. В следующем абзаце мы объясним, как это сделать.
Когда происходит первое включение, макрос EXAMPLE_1_8_H еще не определен, поэтому препроцессор продолжает работу, вводя блок #ifndef-#endif. Следующий оператор определяет макрос EXAMPLE_1_8_H, и препроцессор копирует все в предварительно обработанный код, пока не достигнет директивы #endif. Когда происходит второе включение, макрос EXAMPLE_1_8_H уже определен, поэтому препроцессор пропускает все содержимое раздела #ifndef-#endif и переходит к следующему оператору после #endif, если он есть.
Это так. общепринятая практика заключается в том, что все содержимое файла заголовка помещается между парой #ifndef-#endif, и ничего, кроме комментариев, не остается снаружи.
И последнее замечание в этом разделе: вместо пары #ifndef -#endif, можно использовать #pragma один раз, чтобы защитить заголовочный файл от проблемы двойного включения. Разница между условными директивами и директивой #pragma Once заключается в том, что последняя не является стандартом C, несмотря на то, что она поддерживается почти всеми препроцессорами C. Однако лучше не использовать его, если требуется переносимость вашего кода.
Следующий блок кода содержит демонстрацию того, как использовать #pragma один раз в примере 1. 8, вместо директив #ifndef-#endif:
#pragma once
void say_hello();
int read_age();
Блок кода 1–18: Использование #pragma Once директива как часть примера 1.8
Теперь мы закрываем тему директив препроцессора, пока продемонстрировали некоторые из их интересных характеристик и различных применений. Следующий раздел посвящен указателям на переменные, которые являются еще одной важной особенностью C.
Указатели на переменные.

Идея любого указателя очень проста; это просто простая переменная, которая хранит адрес памяти. Первое, что вы можете вспомнить о них, это символ звездочки *, который используется для объявления указателя в C. Вы можете увидеть его в примере 1.9. В следующем блоке кода показано, как объявить и использовать указатель переменной:
int main(int argc, char** argv) {
int var = 100;
int* ptr = 0;< br /> ptr =
*ptr = 200;
return 0;
}
Блок кода 1–19: Пример объявления и использования указателя в C
В предыдущем примере есть все, что вам нужно знать о синтаксисе указателя. Первая строка объявляет переменную var поверх сегмента стека. Мы обсудим сегмент стека в статье 4 «Структура памяти процесса». Вторая строка объявляет указатель ptr с начальным значением, равным нулю. Указатель, имеющий нулевое значение, называется нулевым указателем. Пока указатель ptr сохраняет свое нулевое значение, он считается нулевым указателем. Очень важно аннулировать указатель, если вы не собираетесь сохранять действительный адрес при объявлении.
Как вы видите в блоке кода 1–19, заголовочный файл не включен. Указатели являются частью языка C, и вам не нужно ничего включать, чтобы иметь возможность их использовать. Действительно, у нас могут быть программы на C, которые вообще не включают в себя заголовочный файл.
Все следующие объявления допустимы в C:
int* ptr = 0;
int * ptr = 0;
int *ptr = 0;
Третья строка функции main вводит оператор &, который называется оператором ссылки. Он возвращает адрес переменной рядом с ним. Нам нужен этот оператор для получения адреса переменной. В противном случае мы не сможем инициализировать указатели допустимыми адресами.
В той же строке возвращаемый адрес сохраняется в указателе ptr. Теперь указатель ptr больше не является нулевым указателем. В четвертой строке мы видим еще один оператор перед указателем, который называется оператором разыменования и обозначается *. Этот оператор позволяет вам иметь косвенный доступ к ячейке памяти, на которую указывает указатель ptr. Другими словами, он позволяет вам читать и изменять переменную var через указатель, указывающий на нее. Четвертая строка эквивалентна var = 200; оператор.
Нулевой указатель не указывает на действительный адрес памяти. Поэтому следует избегать разыменования нулевого указателя, так как это рассматривается как неопределенное поведение, которое обычно приводит к сбою.
И последнее замечание относительно предыдущего примера: обычно у нас есть макрос NULL по умолчанию, определенный со значением 0, и его можно использовать для обнуления указателей при объявлении. Хорошей практикой является использование этого макроса вместо 0 напрямую, потому что это упрощает различие между переменными и указателями:
char* ptr = NULL;
Блок кода 1–20: Использование NULL макрос для обнуления указателя
Указатели в C++ точно такие же, как и в C. Их нужно обнулять, сохраняя в них 0 или NULL, но в C++11 есть новое ключевое слово для инициализации указателей. Это не макрос, например NULL, и не целое число, например 0. Ключевое слово nullptr может использоваться для обнуления указателей или проверки того, являются ли они нулевыми. В следующем примере показано, как он используется в C++11:
char* ptr = nullptr;
Блок кода 1–21: Использование nullptr для обнуления указателя в C++11
Это важно помнить, что указатели должны быть инициализированы при объявлении. Если вы не хотите сохранять действительные адреса памяти при их объявлении, не оставляйте их неинициализированными. Сделайте его нулевым, назначив 0 или NULL! Сделайте это, иначе вы можете столкнуться с фатальной ошибкой!
В большинстве современных компиляторов неинициализированный указатель всегда обнуляется. Это означает, что начальное значение равно 0 для всех неинициализированных указателей. Но это не должно рассматриваться как предлог для объявления указателей без их надлежащей инициализации. Имейте в виду, что вы пишете код для разных архитектур, старых и новых, и это может вызвать проблемы в устаревших системах. Кроме того, вы получите список ошибок и предупреждений для таких неинициализированных указателей в большинстве профилировщиков памяти. Профилировщики памяти будут подробно описаны в статье 4 "Структура памяти процесса" и статье 5 "Стек и куча".
Арифметика указателей переменных

Простейшее представление о памяти — это очень длинный одномерный массив байтов. Имея в виду эту картину, если вы стоите на одном байте, вы можете перемещаться только вперед и назад по массиву; нет другого возможного движения. Таким образом, это будет то же самое для указателей, адресующих разные байты в памяти. При увеличении указателя указатель перемещается вперед, а при уменьшении — назад. Никакие другие арифметические операции над указателями невозможны.
Как мы уже говорили ранее, арифметические операции над указателями аналогичны перемещениям в массиве байтов. Мы можем использовать этот рисунок, чтобы ввести новое понятие: размер арифметического шага. Нам нужна эта новая концепция, потому что, когда вы увеличиваете указатель на 1, он может перемещаться вперед более чем на 1 байт в памяти. У каждого указателя есть арифметический размер шага, который означает количество байтов, на которое указатель переместится, если он увеличится или уменьшится на 1. Этот арифметический размер шага определяется типом данных C указателя.
На каждой платформе , у нас есть одна единица памяти, и все указатели хранят адреса внутри этой памяти. Таким образом, все указатели должны иметь одинаковый размер в байтах. Но это не означает, что все они имеют равные арифметические размеры шага. Как мы упоминали ранее, размер арифметического шага указателя определяется его типом данных C.
Например, указатель int имеет тот же размер, что и указатель char, но у них разные размеры арифметического шага. int* обычно имеет размер арифметического шага 4 байта, а char* имеет размер арифметического шага 1 байт. Таким образом, увеличение целочисленного указателя заставляет его двигаться вперед на 4 байта в памяти (добавляет 4 байта к текущему адресу), а увеличение символьного указателя заставляет его двигаться вперед только на 1 байт в памяти. В следующем примере (пример 1.10) показаны арифметические размеры шага двух указателей с двумя разными типами данных:
#include ‹stdio.h›
int main(int argc, char** argv) {
int var = 1;
int* int_ptr = NULL; // обнуляем указатель
int_ptr =
char* char_ptr = NULL;
char_ptr = (char*)
printf("Перед арифметикой: int_ptr: %u, char_ptr: %u \n”,
(целое без знака)int_ptr, (целое без знака)char_ptr);
int_ptr++; // Арифметический шаг обычно 4 байта
char_ptr++; // Шаг арифметики в 1 байт
printf("После арифметики: int_ptr: %u, char_ptr: %u\n",
(unsigned int)int_ptr, (unsigned int)char_ptr);
> return 0;
}
Поле кода 1–22 [ExtremeC_examples_article1_10.c]: размер арифметического шага двух указателей
В следующем окне оболочки показаны выходные данные примера 1. 10. Обратите внимание, что выводимые адреса могут быть разными для двух последовательных запусков на одной машине и даже с одной платформы на другую, поэтому вы, вероятно, наблюдаете разные адреса в своем выводе:
$ gcc ExtremeC_examples_article1_10.c
$ ./a.out
До арифметики: int_ptr: 3932338348, char_ptr: 3932338348
После арифметики: int_ptr: 3932338352, char_ptr: 3932338349
$
Shell Box 1–4: Вывод примера 1.10 после первого запуска
Из сравнения адресов до и после арифметических операций видно, что размер шага для целочисленного указателя составляет 4 байта, а для символьного — 1 байт. Если запустить пример еще раз, указатели, вероятно, ссылаются на какие-то другие адреса, но размеры их арифметических шагов остаются прежними:
$ ./a.out
Перед арифметикой: int_ptr: 4009638060, char_ptr: 4009638060< br /> После арифметики: int_ptr: 4009638064, char_ptr: 4009638061
$
Shell Box 1–5: вывод примера 1.10 после второго запуска
Теперь, когда вы знаете о размерах арифметических шагов, мы можно рассказать о классическом примере использования арифметики указателей для перебора области памяти. Примеры 1.11 и 1.12 предназначены для вывода всех элементов целочисленного массива. Тривиальный подход без использования указателей приведен в примере 1.11, а решение, основанное на арифметике указателей, дано как часть примера 1.12.
В следующем блоке кода показан код для примера 1.11:
#include ‹ stdio.h›
#define SIZE 5
int main(int argc, char** argv) {
int arr[SIZE];
arr[0] = 9;< br /> 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 [ExtremeC_examples_article1_11.c]: Итерация по массиву без использования арифметики указателей
Код в блоке кода 1–23 должен быть вам знаком. Он просто использует счетчик циклов, чтобы обратиться к определенному индексу массива и прочитать его содержимое. Но если вы хотите использовать указатели вместо доступа к элементам через синтаксис индексатора (целое число между [ и ]), это следует делать по-другому. В следующем блоке кода показано, как использовать указатели для обхода границы массива:
#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 [ExtremeC_examples_article1_12.c]: перебор массива с использованием арифметики указателей
Второй подход, продемонстрированный в блоке кода 1–24, использует бесконечный цикл, который прерывается, когда адрес указателя ptr совпадает с последним элементом массива.
Мы знаем, что массивы — это смежные переменные внутри памяти, поэтому увеличение и уменьшение указателя, указывающего на элемент, эффективно заставляет его двигаться вперед и назад внутри массива и в конечном итоге указывать на другой элемент.
Как ясно из предыдущего кода, указатель ptr имеет тип данных int*. Это связано с тем, что он должен указывать на любой отдельный элемент массива, который является целым числом типа int. Обратите внимание, что все элементы массива относятся к одному типу, поэтому они имеют одинаковые размеры. Следовательно, увеличение указателя ptr заставляет его указывать на следующий элемент внутри массива. Как видите, перед циклом for указатель ptr указывает на первый элемент массива и с дальнейшим увеличением перемещается вперед по области памяти массива. Это очень классическое использование арифметики указателей.
Обратите внимание, что в C массив на самом деле является указателем, указывающим на его первый элемент. Итак, в примере фактический тип данных arr — int*. Следовательно, мы могли бы написать строку так:
int* ptr = arr;
Вместо строки:
int* ptr = &arr[0];
Общие указатели

Указатель типа void* называется универсальным указателем. Он может указывать на любой адрес, как и все другие указатели, но мы не знаем его фактический тип данных, следовательно, мы не знаем размер его арифметического шага. Общие указатели обычно используются для хранения содержимого других указателей, но они забывают фактические типы данных этих указателей. Следовательно, универсальный указатель нельзя разыменовать, и с ним нельзя выполнять арифметические действия, поскольку его базовый тип данных неизвестен. Следующий пример (пример 1.13) показывает, что разыменование универсального указателя невозможно:
#include ‹stdio.h›
int main(int argc, char** argv) {
int var = 9;
int* ptr =
void* gptr = ptr;
printf("%d\n", *gptr);
return 0;
}
Блок кода 1–25 [ExtremeC_examples_article1_13.c]. Разыменование универсального указателя приводит к ошибке компиляции!
Если вы скомпилируете предыдущий код с помощью gcc в Linux, вы получите следующую ошибку:
$ gcc ExtremeC_examples_article1_13.c
В функции 'main': предупреждение: разыменование указателя 'void *'
printf("%d\n", *gptr);
^~~~~
ошибка: неправильное использование выражения void
printf("%d\n", *gptr);
$
Shell Box 1–6: Компиляция примера 1.13 в Linux
И если вы скомпилируете его с помощью clang в macOS, сообщение об ошибке будет другим, но оно относится к той же проблеме:
$ clang ExtremeC_examples_article1_13.c
ошибка: тип аргумента 'void' неполный
/> printf("%d \n”, *gptr);
^
Произошла 1 ошибка.
$
Shell Box 1–7: Компиляция примера 1.13 в macOS
Как видите, оба компиляторы не принимают разыменование универсального указателя. На самом деле бессмысленно разыменовывать общий указатель! Итак, чем же они хороши? На самом деле универсальные указатели очень удобны для определения универсальных функций, которые могут принимать широкий диапазон различных указателей в качестве входных аргументов. В следующем примере (пример 1.14) делается попытка раскрыть подробности об общих функциях:
#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)) ;
возвращает 0;
}
Поле кода 1–26 [ExtremeC_examples_article1_14.c]: пример универсальной функции
В предыдущем поле кода функция print_bytes получает адрес как указатель void* и целое число, указывающее длину. Используя эти аргументы, функция печатает все байты, начиная с заданного адреса и до заданной длины. Как видите, функция принимает универсальный указатель, который позволяет пользователю передавать любой указатель, который он хочет. Имейте в виду, что присваивание указателю void (универсальному указателю) не требует явного приведения. Вот почему мы передали адреса a и b без явного приведения типов.
Внутри функции print_bytes мы должны использовать беззнаковый указатель char для перемещения внутри памяти. В противном случае мы не можем выполнять какие-либо арифметические действия с параметром указателя void, data, напрямую. Как вы знаете, размер шага char* или unsigned char* равен одному байту. Таким образом, это лучший тип указателя для перебора диапазона адресов памяти по одному байту за раз и обработки всех этих байтов один за другим.
И последнее примечание к этому примеру: size_t — это стандартные данные без знака. type обычно используется для хранения размеров в C.
size_t определен в разделе 6.5.3.4 стандарта ISO/ICE 9899:TC3. Этот стандарт ISO представляет собой известную спецификацию C99, пересмотренную в 2007 году. Этот стандарт был основой для всех реализаций C до сегодняшнего дня. Ссылка на ISO/ICE 9899:TC3 (2007): «http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1256.pdf».
Размер указателя

Есть много известных проблем, вызванных неправильным использованием указателей. Проблема висячих указателей очень известна. Указатель обычно указывает на адрес, по которому размещена переменная. Чтение или изменение адреса, в котором не зарегистрирована переменная, является большой ошибкой и может привести к сбою или ошибке сегментации. Segmentation fault — это страшная ошибка, с которой каждый разработчик C/C++ должен был столкнуться хотя бы раз во время работы над кодом. Такая ситуация обычно возникает, когда вы неправильно используете указатели. Вы получаете доступ к местам в памяти, которые вам не разрешены. Раньше у вас была переменная, но сейчас она освобождена.
Давайте попробуем воспроизвести эту ситуацию в рамках следующего примера, пример 1.15:
#include ‹stdio.h›
int * create_an_integer(int default_value) {
int var = default_value;
return
}
int main() {
int* ptr = NULL;
ptr = create_an_integer(10);
printf("%d\n", *ptr);
return 0;
}
Поле кода 1–27 [ExtremeC_examples_article1_15.c]: Создание ситуации ошибки сегментации
В предыдущем примере функция create_an_integer используется для создания целого числа. Он объявляет целое число со значением по умолчанию и возвращает его адрес вызывающей стороне. В основной функции принимается адрес созданного целого числа, var, и он сохраняется в указателе ptr. Затем указатель ptr разыменовывается, и выводится значение, хранящееся в переменной var.
Но все не так просто. Когда вы хотите скомпилировать этот код с помощью компилятора gcc на машине с Linux, он выдает следующее предупреждение, но все равно успешно завершает компиляцию, и вы получаете окончательный исполняемый файл:
$ gcc ExtremeC_examples_article1_15.c
В функции 'f':
предупреждение: функция возвращает адрес локальной переменной [-Wreturn-local-addr]
return
^~~~
$
Shell Box 1–8: Компиляция примера 1.15 в Linux
Это действительно важное предупреждающее сообщение, которое программист может легко пропустить и забыть. Мы поговорим об этом позже в части статьи 5 «Стек и куча». Давайте посмотрим, что произойдет, если мы продолжим и выполним полученный исполняемый файл.
Когда вы запустите пример 1.15, вы получите ошибку сегментации, и программа немедленно вылетит:
$ ./a.out
Ошибка сегментации (дамп ядра)
$
Оболочка Box 1–9: Ошибка сегментации при выполнении примера 1.15
Итак, что же пошло не так? Указатель ptr болтается и указывает на уже освобожденную часть памяти, которая, как известно, является местом памяти переменной var.
Переменная var является локальной переменной для функции create_an_integer, и она будет освобождена после выхода из функции, но ее адрес может быть возвращен из функции. Таким образом, после копирования возвращаемого адреса в ptr в рамках основной функции, ptr становится оборванным указателем, указывающим на недопустимый адрес в памяти. Теперь разыменование указателя вызывает серьезную проблему, и программа аварийно завершает работу.
Если вы посмотрите на предупреждение, сгенерированное компилятором, то увидите, что оно ясно указывает на проблему.
В нем говорится, что вы возвращаете адрес локальная переменная, которая будет освобождена после возврата из функции. Умный компилятор! Если вы серьезно отнесетесь к этим предупреждениям, вы не столкнетесь с этими страшными ошибками.
Но как правильно переписать пример? Да, используя память Heap. Мы полностью рассмотрим память кучи в статье 4, Структура памяти процесса, и статье 5, Стек и куча, но сейчас мы перепишем пример с использованием выделения кучи, и вы увидите, какие выгоды вы можете получить от использования кучи вместо Stack.
В приведенном ниже примере 1.16 показано, как использовать память кучи для выделения переменных и включения передачи адресов между функциями без каких-либо проблем:
#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;
}
Блок кода 1–28 [ExtremeC_examples_article1_16.c]: переписывание примера 1.15 с использованием динамической памяти
As вы видите в предыдущем блоке кода, мы включили новый заголовочный файл, stdlib.h, и мы используем две новые функции, malloc и free. Простое объяснение таково: созданная целочисленная переменная внутри функции create_an_integer больше не является локальной переменной. Вместо этого это переменная, выделенная из памяти кучи, и ее время жизни не ограничено функцией, объявляющей ее. Следовательно, к нему можно получить доступ в вызывающей (внешней) функции. Указатели, указывающие на эту переменную, больше не болтаются, и их можно разыменовать, пока переменная существует и не освобождается. В конце концов, переменная освобождается, вызывая функцию free в конце срока ее существования. Обратите внимание, что освобождение переменной кучи является обязательным, когда она больше не нужна.
В этом разделе мы рассмотрели все основные обсуждения, касающиеся указателей на переменные. В следующем разделе мы поговорим о функциях и их строении в C.
Некоторые подробности о функциях

В большинстве книг по компьютерному программированию есть раздел, посвященный передаче по значению и передаче по ссылке в отношении аргументов, передаваемых функции. К счастью или к сожалению, в C у нас есть только передача по значению.
В C нет ссылки, поэтому нет и передачи по ссылке. Все копируется в локальные переменные функции, и вы не можете прочитать или изменить их после выхода из функции.
Несмотря на множество примеров, демонстрирующих вызовы функций с передачей по ссылке, я должен сказать, что передача по ссылке — это иллюзия в C. В оставшейся части этого раздела мы хотим раскрыть эту иллюзию и убедить вас, что эти примеры также являются передачей по значению. Следующий пример продемонстрирует это:
#include ‹stdio.h›
void func(int a) {
a = 5;
}
int main(int argc, char** argv) {
int x = 3;
printf("Перед вызовом функции: %d\n", x);
func(x);
printf("После вызова функции: %d\n", x);
return 0;
}
Поле кода 1–29 [ExtremeC_examples_article1_17.c]: пример обхода вызов функции -value
Вывод легко предсказать. В переменной x ничего не меняется, потому что она передается по значению. В следующем окне оболочки показан вывод примера 1.17 и подтверждается наш прогноз:
$ gcc ExtremeC_examples_article1_17.c
$ ./a.out
До вызова функции: 3
После вызова функции : 3
$
Shell Box 1–10: вывод примера 1.17
Следующий пример, пример 1.18, демонстрирует, что передача по ссылке не существует в C:
#include ‹stdio.h›
void func(int* a) {
int b = 9;
*a = 5;
a =
}
int main(int argc, char** argv) {
int x = 3;
int* xptr =
printf("Значение перед вызовом: %d\n", x);< br /> printf("Указатель перед вызовом функции: %p\n", (void*)xptr);
func(xptr);
printf("Значение после вызова: %d\n", x);
printf("Указатель после вызова функции: %p\n", (void*)xptr);
return 0;
}
Code Box 1–30 [ ExtremeC_examples_article1_18.c]: Пример вызова функции с передачей по указателю, который отличается от передачи по ссылке
И это результат:
$ gcc ExtremeC_examples_article1_ 18.c
$ ./a.out
Значение до вызова: 3
Указатель перед вызовом функции: 0x7ffee99a88ec
Значение после вызова: 5
Указатель после вызова функции: 0x7ffee99a88ec
$
Shell Box 1–11: вывод примера 1.18
Как видите, значение указателя не меняется после вызова функции. Это означает, что указатель передается как аргумент, передаваемый по значению. Разыменование указателя внутри функции func позволило получить доступ к переменной, на которую указывает указатель. Но вы видите, что изменение значения параметра-указателя внутри функции не меняет его эквивалентного аргумента в вызывающей функции. Во время вызова функции в C все аргументы передаются по значению, а разыменование указателей позволяет модифицировать переменные вызывающей функции.
Стоит добавить, что приведенный выше пример демонстрирует пример передачи по указателю, в котором мы передаем указатели на переменные вместо прямой передачи. Обычно рекомендуется использовать указатели в качестве аргументов вместо передачи больших объектов в функцию, но почему? Легко догадаться. Копирование 8 байтов аргумента указателя намного эффективнее, чем копирование сотен байтов большого объекта.
Удивительно, но в приведенном выше примере передача указателя не эффективна! Это связано с тем, что тип int занимает 4 байта, и его копирование более эффективно, чем копирование 8 байтов его указателя. Но это не относится к структурам и массивам. Поскольку копирование структур и массивов выполняется побайтно, и все байты в них должны быть скопированы один за другим, вместо этого обычно лучше передавать указатели.
Теперь, когда мы рассмотрели некоторые детали функций в C, давайте поговорим об указателях на функции.
Указатели на функции

Наличие указателей на функции — еще одна супер-возможность языка программирования C. Два предыдущих раздела были посвящены указателям на переменные и функциям, а в этом разделе мы их объединим и поговорим о более интересной теме: указателях на функции.
У них много приложений, но разбиение большого двоичного файла на более мелкие их снова в другой небольшой исполняемый файл является одним из самых важных приложений. Это привело к модульности и разработке программного обеспечения. Указатели функций являются строительными блоками для реализации полиморфизма в C++ и позволяют нам расширять нашу существующую логику. В этом разделе мы рассмотрим их и подготовим вас к более сложным темам, которые мы рассмотрим в следующих статьях.
Подобно указателю переменной, адресующему переменную, указатель функции адресует функцию и позволяет вам вызывать ее. функционировать косвенно. Следующий пример, пример 1.19, может быть хорошим началом для этой темы:
#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("Сумма: %d\n", результат);
func_ptr =
результат = func_ptr(5, 4);
printf("Вычесть: %d\n", результат);
вернуть 0;
> }
Блок кода 1–31 [ExtremeC_examples_article1_19.c]: использование одного указателя на функцию для вызова разных функций
В предыдущем блоке кода func_ptr — это указатель на функцию. Он может указывать только на определенный класс функций, соответствующих его сигнатуре. Сигнатура ограничивает указатель указателем только на функции, которые принимают два целочисленных аргумента и возвращают целочисленный результат.
Как видите, мы определили две функции с именами sum и subtract, соответствующие сигнатуре указателя func_ptr. В предыдущем примере указатель функции func_ptr используется для указания функций суммирования и вычитания по отдельности, затем вызывается их с теми же аргументами и сравниваются результаты. Это вывод примера:
$ gcc ExtremeC_examples_article1_19.c
$ ./a.out
Сумма: 9
Вычитание: 1
$
Блок оболочки 1–12: вывод примера 1.19
Как вы видите в примере 1.19, мы можем вызывать разные функции для одного и того же списка аргументов, используя один указатель на функцию, и это важная особенность. Если вы знакомы с объектно-ориентированным программированием, первое, что приходит на ум, это полиморфизм и виртуальные функции. Фактически это единственный способ поддерживать полиморфизм в C и имитировать виртуальные функции C++. Мы рассмотрим ООП в третьей части книги, «Ориентация объекта».
Как и указатели на переменные, важно правильно инициализировать указатели на функции. Для тех указателей на функции, которые не будут инициализированы сразу после объявления, обязательно сделать их нулевыми. Обнуление указателей на функции продемонстрировано в предыдущем примере и очень похоже на указатели на переменные.
Обычно рекомендуется определять псевдоним нового типа для указателей на функции. Следующий пример, пример 1.20, демонстрирует, как это должно быть сделано:
#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”, результат);
func_ptr = &less_than_modular;
результат = func_ptr(3, 7);
printf(“ %d\n», результат);
вернуть 0;
}
Блок кода 1–32 [ExtremeC_examples_article1_20.c]: Использование одного указателя на функцию для вызова разных функций
Ключевое слово typedef позволяет определить псевдоним для уже определенного типа. В предыдущем примере есть два новых псевдонима типа: bool_t, который является псевдонимом для типа int, и тип less_than_func_t, который является псевдонимом для типа указателя на функцию, bool_t (*)(int, int). Эти псевдонимы улучшают читаемость кода и позволяют выбрать более короткое имя для длинного и сложного типа. В C имя нового типа обычно заканчивается на _t по соглашению, и вы можете найти это соглашение во многих других стандартных псевдонимах типов, таких как size_t и time_t.
Структуры

Итак, зачем нам создавать новые типы в программе? Ответ на этот вопрос раскрывает принципы проектирования программного обеспечения и методы, которые мы используем для нашей повседневной разработки программного обеспечения. Мы создаем новые типы, потому что делаем это каждый день, используя наш мозг в рутинном анализе.
Мы не смотрим на наше окружение как на целые числа, двойники или символы. Мы научились группировать связанные атрибуты под одним и тем же объектом. Мы обсудим больше того, как мы анализируем наше окружение, в статье 6, ООП и инкапсуляция. Но в качестве ответа на наш исходный вопрос нам нужны новые типы, потому что мы используем их для анализа наших проблем на более высоком уровне логики, достаточно близком к нашей человеческой логике.
Здесь вам нужно ознакомиться с термином бизнес. логика. Бизнес-логика — это набор всех сущностей и правил, встречающихся в бизнесе. Например, в бизнес-логике банковской системы вы сталкиваетесь с такими понятиями, как клиент, счет, баланс, деньги, наличные деньги, платеж и многими другими, которые необходимы для того, чтобы такие операции, как снятие денег, были возможными и осмысленными.
Предположим, что вам нужно объяснить некоторую банковскую логику с помощью чистых целых чисел, чисел с плавающей запятой или символов. Это почти невозможно. Если это возможно для программистов, то для бизнес-аналитиков это почти бессмысленно. В реальной среде разработки программного обеспечения с четко определенной бизнес-логикой программисты и бизнес-аналитики тесно сотрудничают. Поэтому они должны иметь общий набор терминологии, глоссарий, типы, операции, правила, логику и т. д.
Сегодня язык программирования, который не поддерживает новые типы в своей системе типов, можно рассматривать как мертвый язык. Возможно, именно поэтому большинство людей считают C мертвым языком программирования, главным образом потому, что они не могут легко определить свои новые типы в C и предпочитают перейти на язык более высокого уровня, такой как C++ или Java. Да, создать красивую систему типов на C не так-то просто, но все необходимое там есть.
Даже сегодня может быть много причин для выбора C в качестве основного языка проекта и принятия усилий по созданию и поддержание хорошей системы типов в проекте C, и даже сегодня многие компании делают это.
Несмотря на то, что нам нужны новые типы в нашем ежедневном анализе программного обеспечения, процессоры не понимают эти новые типы. Процессоры стараются придерживаться PDT и быстрых вычислений, потому что они предназначены для этого. Итак, если у вас есть программа, написанная на вашем языке высокого уровня, ее следует перевести на инструкции уровня ЦП, а это может стоить вам больше времени и ресурсов.
В этом смысле, к счастью, C не очень далек от логики уровня процессора и имеет систему типов, которую можно легко транслировать. Возможно, вы слышали, что C — это низкоуровневый или аппаратный язык программирования. Это одна из причин, по которой некоторые компании и организации даже сегодня пытаются писать и поддерживать свои основные фреймворки на C.
Что делают структуры?