Основы программирования
Переменные
Заголовок раздела «Переменные»Переменная представляет собой именованный участок памяти. Компьютер выделит (предоставит) этот участок памяти при выполнении нашей программы.
У каждой переменной есть связанный с ней тип.
Пока думайте о типе как о концептуальном имени, определяющем число байтов памяти,
которые потребуется выделить под переменную.
Например, тип std::byte означает, что размер составит 1 байт памяти,
int — (как правило) 4 байта,
а size_t — (как правило) 8 байтов.
Объявление
Заголовок раздела «Объявление»Чтобы объявить переменную (заставить компьютер выделить под неё память и дать этой памяти имя в исходном коде), нужно написать тип и имя переменной (то, что она должна представлять). Например:
// Объявляем int, то есть выделяем 4 байта памяти и называем их "i"int i;// Объявляем byte, то есть выделяем 1 байт памяти и называем его "myByte"std::byte myByte;Инициализация
Заголовок раздела «Инициализация»Переменную также нужно инициализировать — то есть записать значение в память этой переменной.
По умолчанию память любой переменной «не инициализирована»: она содержит то, что было в этой памяти раньше, а на практике это может быть что угодно
(это часто называют «мусором» или «мусорными данными»).
Обычно стоит сразу «обнулить» значение, если вы планируете пользоваться переменной дальше.
Обнулить значение — значит записать ноль в каждый байт памяти переменной.
В C++ для этого переменной присваивается { } (если это числовой тип, вроде int, можно использовать и 0).
Определение
Заголовок раздела «Определение»Объявление переменной вместе с инициализацией называют также определением переменной.
Тонкость: объявление и определение — разные вещи. Определение выделяет память под переменную, объявление лишь сообщает о ее существовании (например,
extern int value;— объявление без определения). Локальная переменная без инициализатора, вродеint value;, остается с неопределенным (мусорным) значением: читать его нельзя, это неопределенное поведение. А вот переменные со статическим временем хранения (глобальные и статические) вместо мусора инициализируются нулем. В простых случаях объявление совпадает с определением, поэтому дальше я не буду их различать.
// Определяем int и заполняем все 4 байта нулями.int i = 0;// То же самоеint number = { };
// Определяем int и устанавливаем 4 байта равными 42.// Это корректно установит битовую схему 4 байтов для представления 42 в двоичной системе.// Значение каждого байта в десятичной системе: 0 0 0 42// А вот то же в двоичной: 00000000 00000000 00000000 00101010int someNumber = 42;
// Объявляем int, затем инициализируем его отдельно.// Между объявлением и инициализацией переменная будет содержать мусорные данные.// ... Объявлениеint g;// ... Инициализацияg = 10;🤓 размер
int(4 байта), размер байта и порядок байтов в памяти — деталь реализации. Стандарт технически гарантирует меньше: он фиксирует только диапазон значенийint, а конкретная битовая схема и расположение байтов не оговариваются вовсе. На практике же практически любой современный компилятор на любом современном железе устроит именно так, как написано выше.
Присваивание
Заголовок раздела «Присваивание»Инициализацией обычно называют первый случай, когда вы записываете значение в переменную. Но в принципе перезаписывать значение переменной можно сколько угодно раз. Это называется присваиванием (assignment), а само действие по установке значения — присвоить (assign).
// Определяем несколько чиселint a = 5;int b = 10;int c = 20;
// Определяем переменную для суммы, инициализированную нулем.int sum = 0;// Прибавляем значение каждой переменной к сумме.sum = sum + a; // 0 + 5 = 5sum = sum + b; // 5 + 10 = 15sum = sum + c; // 15 + 20 = 35Массивы
Заголовок раздела «Массивы»Массив — это непрерывный блок памяти, в котором каждый элемент массива имеет один и тот же тип (то есть занимает одинаковое число байтов). Можно думать о массиве как о группе переменных, где каждая переменная именуется своим индексом в массиве.
Чтобы наглядно увидеть, как массив устроен в памяти, посмотрите memory_example_1.
// Объявляет массив из 5 int, то есть 5 * 4 байта = 20 байтов.// Фактически это означает, что мы объявляем 5 переменных типа int,// и они носят имена array[0], array[1], array[2], array[3], array[4].int array[5];
std::byte array[2];Индексация массива
Заголовок раздела «Индексация массива»Массивы индексируются с нуля, поэтому первый элемент — это array[0], а не array[1].
Думайте об индексе как о смещении от начала памяти массива,
посчитанном в размерах типа каждого элемента.
В ascii-таблице ниже представлены первые 12 байтов памяти массива int.
address: 0 1 2 3 4 5 6 7 8 9 10 11 ...bytes graphic: | _ _ _ _ | _ _ _ _ | _ _ _ _ | ...offset (index): 0 1 2 3 ...Элемент массива, например array[0], можно использовать как любую обычную переменную:
присваивать ему значение, читать его, обнулять присваиванием { }.
Но одно из самых полезных свойств массивов — что индекс может быть непостоянным. Например:
int array[3] = { };
size_t index = 2;// Устанавливает array[2] равным 69.array[index] = 69;
size_t otherIndex = 1;// Устанавливает array[1] равным 420.array[otherIndex] = 420;Это полезно, когда индекс приходит откуда-то еще: например, от пользовательского ввода или просто из другой функции.
Указатели
Заголовок раздела «Указатели»Если вы понимаете, что такое адреса памяти, вы практически понимаете и указатели. Указатель — это переменная, хранящая адрес памяти. Пример того, как указатель хранится в памяти, — в memory_example_1.
int someNumber = 1;// Объявляем указатель, хранящий адрес памяти `someNumber`.// Оператор & называется «адрес» («address of»), и он// возвращает адрес памяти переменной, к которой применяется.int* pointerToSomeNumber = &someNumber;Размеры, адреса и size_t
Заголовок раздела «Размеры, адреса и size_t»Тип указателя определяет, на переменную какого типа указывает адрес, однако этот тип не влияет на размер самого адреса. Адрес переменной любого типа всегда имеет один и тот же размер: 8 байтов (64 бита) на 64-битных архитектурах или 4 байта (32 бита) на 32-битных.
Технически размер может быть любым, но в 99,9% случаев это один из двух. Для дальнейшего разговора считайте, что это 8 байтов.
Специальный тип size_t может содержать любое число вплоть до максимальной длины блока памяти в байтах.
На большинстве архитектур он также совпадает с размером адреса.
Обычно он используется для индексов или длин массивов.
Тип, способный хранить любой указатель, — uintptr_t;
на всех современных процессорах в персональных компьютерах он имеет тот же размер, что и size_t.
Числовое значение адреса можно получить, преобразовав его в uintptr_t.
Преобразование из одного типа в другой называется «приведением типа» (casting).
int someNumber = 1;int* pointerToSomeNumber = &someNumber;// Приводим указатель к uintptr_t, интерпретируя хранящийся в нем адрес памяти как число.uintptr_t addressOfSomeNumber = (uintptr_t) pointerToSomeNumber;🤓 существование
uintptr_tи результат такого преобразования — деталь реализации. Стандарт технически не требует наличия этого типа вовсе (он опционален), а преобразование указателя в число — implementation-defined: полученное число не обязано быть осмысленным адресом. На практике же у любого современного компилятора на любом современном железе этот тип есть, и это просто «плоский» адрес памяти.
Разыменование
Заголовок раздела «Разыменование»«Разыменование» (dereferencing) означает получение значения по адресу, хранящемуся в указателе.
Иными словами, оно означает:
- Взять адрес, хранящийся в памяти переменной-указателя;
- Перейти по этому адресу в памяти;
- Прочитать значение по этому адресу.
int value = 0;int* pointerToValue = &value;
// Разыменовываем указатель, то есть получаем значение по адресу, хранящемуся в указателе.// `valueFromPointer` будет равно 0.int valueFromPointer = *pointerToValue;
value = 10;
// Снова разыменовываем указатель, то есть получаем значение по адресу, хранящемуся в указателе.// `valueFromPointer` останется равным 0// (потому что это копия прежнего значения, лежащая в памяти valueFromPointer)// а `otherValueFromPointer` будет равно 10 (прочитано из `value`).int otherValueFromPointer = *pointerToValue;Запись по адресу памяти
Заголовок раздела «Запись по адресу памяти»Эта операция отличается от разыменования, хотя синтаксис у них одинаковый. Она означает запись значения в память по адресу, который хранится в указателе.
Иными словами:
- Взять адрес, хранящийся в памяти переменной-указателя;
- Записать по этому адресу в памяти.
int value = 0;int* pointerToValue = &value;
// `value` становится равным 10.*pointerToValue = 10;Указатели на указатели
Заголовок раздела «Указатели на указатели»Указатели могут указывать на другие указатели — и так до бесконечности. Указатель на указатель означает, что первый указатель хранит адрес памяти, где находится другой указатель, — то есть там, где тот хранит адрес переменной, на которую указывает.
Время от времени они полезны, но в сыром виде используются редко. Двойная и даже тройная косвенность (многократное разыменование указателей в программе) встречается очень часто, но обычно она спрятана за более понятной абстракцией. Указатели на указатели в сыром виде тяжело укладываются в голове. Если вам так кажется — знайте, это нормально.
int value = 0;int otherValue = 5;int* pointerToValue = &value;int** pointerToPointerToValue = &pointerToValue;
// Мы можем получить значение `pointerToValue`, то есть адрес, хранящийся в его памяти,// разыменовав `pointerToPointerToValue`.int* anotherPointerToValue = *pointerToPointerToValue;
// Теперь можно писать в `value`, записывая в `*anotherPointerToValue`.// Так `value` станет равным 10.*anotherPointerToValue = 10;
// А теперь направим `pointerToValue` на `otherValue`.pointerToValue = &otherValue;
// Это даст 5.int valueOfOtherValue = *pointerToValue;
// Но здесь все еще старый адрес `value`, поэтому читается 10.int valueOfValue = *anotherPointerToValue;
// А двойное разыменование `pointerToPointerToValue` дает новый адрес `otherValue`.int valueOfOtherValueAgain = **pointerToPointerToValue;Вопрос на засыпку: сколько байтов занимает тип int**?
Ответ
`uintptr_t` (как правило, 8 байтов), как и любой другой адрес памяти.Тип void* — особый тип указателя, который может указывать на переменную любого типа.
int value = 0;std::byte otherValue = 5;
// Сохраняем адрес `value` в `void*`.void* pointer = &value;
// Эта строка не скомпилируется (void* нельзя разыменовать).// int valueFromPointer = *pointer;
// Но можно сначала привести его к int*, а затем разыменовать — это сработает.int* typedPointer = (int*) pointer;int valueFromPointer = *typedPointer;// или одной операциейint valueFromPointer = * (int*) pointer;Указатели в массивы и арифметика указателей
Заголовок раздела «Указатели в массивы и арифметика указателей»Видео. Оно про C, поэтому с синтаксисом структур есть небольшая разница.
Как и с любой другой переменной, указатель можно навести на элемент массива.
int array[3] = { 1, 2, 3 };
// Указываем на первый элемент массива.int* pointerToArray = &array[0];// Указываем на второй элемент массива.int* pointerToArray = &array[1];
int i = 2;// Указатель на некоторый i-й элемент массива.int* pointerToArray = &array[i];К указателям можно прибавлять и вычитать числа, перемещаясь по блокам памяти (например, массивам) с шагом, равным размеру типа указателя.
int array[3] = { 1, 2, 3 };
// Указываем на первый элемент массива.int* pointerToArray = &array[0];int value1 = *pointerToArray; // 1
// Переходим ко второму элементу.// Заметьте: +1 меняет адрес не на один байт, а в данном случае на 4 байта.pointerToArray = pointerToArray + 1;int value2 = *pointerToArray; // 2
// Возвращаемся к первому элементуpointerToArray = pointerToArray - 1;int value3 = *pointerToArray; // 1
// Переходим к третьему элементу, перепрыгнув через второй.pointerToArray = pointerToArray + 2;int value4 = *pointerToArray; // 3При арифметике указателей void* ведет себя как std::byte*, то есть
перемещается по памяти с шагом в 1 байт.
🤓 арифметика по
void*— деталь реализации (компиляторное расширение). Стандарт технически ее не определяет; переносимый способ двигаться по памяти побайтово — привести указатель кstd::byte*илиchar*. На практике GCC и Clang на любом современном железе ведут себя именно так, как написано выше.
Еще стоит упомянуть: из одного указателя можно вычесть другой и получить число элементов между ними.
ptrdiff_t— знаковый вариантsize_t.
int array[3] = { 1, 2, 3 };int* pointerToArray0 = &array[0];int* pointerToArray2 = &array[2];ptrdiff_t numberOfItems = pointerToArray2 - pointerToArray0; // 2ptrdiff_t negativeNumberOfItems = pointerToArray0 - pointerToArray2; // -2Если нужно узнать разницу между двумя указателями в байтах,
можно сначала привести их к std::byte*, uintptr_t или ptrdiff_t.
Кроме того, два void* или указатели разных типов вычитать нельзя, если сначала их не привести.
Лучший вариант, вероятно,
std::byte*: он не создает странностей со знаковостью. При вычитанииuintptr_tнужно быть уверенным, что левый операнд больше, а приведение кptrdiff_tтеряет один бит информации (знаковый). Вычитание указателей типов, отличных отstd::byte*, быстро уводит в территорию вопросов «определено ли для этого типа переполнение или антипереполнение, или это неопределенное поведение?», которых лучше просто избегать.
int array[3] = { 1, 2, 3 };int* pointerToArray0 = &array[0];int* pointerToArray2 = &array[2];
uintptr_t numberOfBytes = (uintptr_t) pointerToArray2 - (uintptr_t) pointerToArray0; // 8// илиptrdiff_t numberOfBytes = (ptrdiff_t) pointerToArray2 - (ptrdiff_t) pointerToArray0; // 8// илиptrdiff_t numberOfBytes = (std::byte*) pointerToArray2 - (std::byte*) pointerToArray0; // 8Складывать два указателя тоже нельзя (если подумать, непонятно, зачем это вообще делать).
Заметьте: стандарт C++ разрешает вычитать указатели, только если они указывают на элементы одного массива / блока памяти. Иначе это неопределенное поведение (может случиться что угодно, не полагайтесь на это).
Функции
Заголовок раздела «Функции»Функции — это блоки кода, которые можно выполнить из других мест программы. На языке инструкций функция — это последовательность инструкций, у которой есть адрес начала в исполняемом файле. Уточним: каждая инструкция в исполняемом файле имеет адрес — так же, как и память, это позиция инструкции в файле с некоторым базовым смещением (скажем, 100). Это смещение может отличаться в разных запусках программы, но относительное расположение инструкций в файле всегда одно и то же.
TODO: кто-нибудь должен это перепроверить; я просто пересказываю свое понимание и могу слегка ошибаться в деталях.
Когда вы вызываете функцию в коде, процессор «прыгает» на адрес первой инструкции функции и начинает выполнять инструкции оттуда. Когда функция заканчивает выполняться, процессор «прыгает» обратно — на инструкцию, следующую за той, что вызвала функцию.
Выполнение функции называется «вызовом» (call) или «инициированием» (invoke) функции, а возврат из функции после завершения выполнения — «возвратом» (return) из функции. Говорят, что вызванная вами функция «возвращает управление».
Функция main
Заголовок раздела «Функция main»Функция с именем main — особая функция, вызываемая автоматически при запуске программы.
Она называется точкой входа программы.
Не беспокойтесь пока о
include,intилиreturn 0;.
#include <iostream>int main(){ // Печатает "Hello" в консоль при выполнении. std::cout << "Hello"; return 0;}
return 0означает успех, а возврат любого другого значения — неудачу.
Прототипы
Заголовок раздела «Прототипы»Прототип (в других языках — сигнатура) — это описание интерфейса функции. Он описывает, какие «параметры» (входы) принимает функция и что она «возвращает» (выходы).
На высоком уровне функцию можно представить как фабрику:
- Она принимает что-то (параметры), например сталь и пластик;
- Выполняет над этим какие-то операции;
- На выходе получается нечто иное (возвращаемое значение), например автомобиль.
Вероятно, вам знакома математическая запись ; она говорит, что:
- функция называется
f; - она принимает параметры
x,yиz; - она возвращает некоторое значение, являющееся целым числом (
Z).
Заметьте, в такой математической записи типы параметров (то, какие значения они могут принимать) часто подразумеваются из контекста, но их можно указать и явно, отдельно, например вот так: .
В C++ происходит то же самое. У любой функции есть тип возврата, имя и список параметров, где у каждого есть имя и тип.
Например, если приблизить типом int, объявить такую функцию в C++ можно вот так:
int f(int x, int y);Это называется прототипом функции или сигнатурой функции.
Здесь мы объявляем функцию, которая возвращает некоторый указатель и принимает указатель на int и int.
void* getSomePointer(int* pointer, int someInt);Процедуры
Заголовок раздела «Процедуры»Процедура — это функция, у которой нет типа возврата, или которая ничего не возвращает в привычном смысле слова. Она все равно может влиять на память вызывающего через свои параметры, например изменять значение через переданный в параметрах указатель. Также она может производить видимый побочный эффект, например выводить что-то в консоль.
Тип возврата процедуры помечается как void, что означает «нет типа возврата».
// Вот процедура, которая просто печатает приветствие в консоль.// (производит побочный эффект)void printHello(){ std::cout << "Hello";}
// Вот процедура, которая принимает указатель на int// и записывает по нему значение.// (влияет на память вызывающего)void writeValue(int* pointer){ *pointer = 10;}Объявление функции
Заголовок раздела «Объявление функции»Функции похожи на переменные тем, что у них есть объявление и определение. Однако, в отличие от переменных, функции по умолчанию не инициализируются мусором: простое объявление ни на что не годится, если где-то в программе нет и определения.
Например, следующая программа не скомпилируется из-за ошибки линковки:
у add нет определения.
int add(int a, int b);
int main(){ int c = add(1, 2); return 0;}Определение функции
Заголовок раздела «Определение функции»Определение функции — это её фактическая реализация. Его также называют телом функции.
В примере выше, если бы мы определили тело add (какие инструкции он должен выполнять),
программа скомпилировалась бы и работала корректно.
// объявлениеint add(int a, int b);
// определениеint add(int a, int b){ return a + b;}
int main(){ int c = add(1, 2); return 0;}Определение можно разместить в любом месте программы.
Пока определение только одно. Может случиться так, что у одной функции окажется несколько определений: так бывает, когда одна и та же функция определена в нескольких исходных файлах, которые вы затем пытаетесь слинковать в один исполняемый файл. Линковка не пройдет — линковщик выдаст ошибку о том, что определений больше одного.
// объявлениеint add(int a, int b);
int main(){ int c = add(1, 2); return 0;}
// определение// В данном случае оно размещено после вызова в файле.int add(int a, int b){ return a + b;}Ограничение: объявление должно идти до вызова функции. Следующее не скомпилируется:
int main(){ int c = add(1, 2); return 0;}
// объявлениеint add(int a, int b);Объявление можно объединить с определением — аналогично тому, как это делается с переменными:
// объявление + определениеint add(int a, int b){ return a + b;}
int main(){ int c = add(1, 2); return 0;}Локальные переменные попадают на стек
Заголовок раздела «Локальные переменные попадают на стек»сюда нужно вставить объяснение, увы.
Пример 3 показывает, как работают разные виды памяти.
Рекурсия (функции в функциях)
Заголовок раздела «Рекурсия (функции в функциях)»сюда нужно вставить объяснение, увы.
Пример 4 показывает, как работает рекурсия.
Структуры
Заголовок раздела «Структуры»Пример того, как структуры располагаются в памяти, — в memory_example_2.
Структура — это способ сгруппировать несколько переменных. Формально структура — это определяемый программистом пользовательский тип данных. Переменные, объявленные в структуре, называются полями.
Ниже мы объявляем структуру, объединяющую две переменные int с именами a и b.
struct TwoInts{ int a; int b;};А ниже мы объявляем структуру «Car» для хранения данных об автомобиле.
struct Car{ Color color; // еще одна пользовательская структура, определенная в другом месте int numberOfDoors; // int int wheelLastChangedYear[4]; // массив из 4 int};Использование и оператор доступа к полю
Заголовок раздела «Использование и оператор доступа к полю»Чтобы использовать структуру, нужно объявить переменную этого типа.
После этого к полям структуры можно обращаться через оператор ..
struct Point{ int x; int y;};
int main(){ Point point; point.x = 10; point.y = 20;
// 10 + 20 = 30 int a = point.x + point.y;
return 0;}Структура целиком носит имя point, а переменные внутри —
имена point.x и point.y (их имя из объявления с префиксом point.).
Думайте об операторе . как о проникновении в память всей точки
и извлечении нужной переменной.
Копирование структур
Заголовок раздела «Копирование структур»Как у вас может быть несколько переменных одного примитивного типа,
так может быть и несколько переменных одного сложного типа, например Point.
struct Point{ int x; int y;};
int main(){ Point point1; point1.x = 10; point1.y = 20;
Point point2; point2.x = 30; point2.y = 40;
// 10 + 20 = 30 int a = point1.x + point1.y; // 30 + 40 = 70 int b = point2.x + point2.y;
return 0;}Точки можно и полностью копировать друг в друга. Это означает копирование всех байтов, благодаря чему переменные внутри одной получают значения из другой.
struct Point{ int x; int y;};
int main(){ Point point1; point1.x = 10; point1.y = 20;
Point point2; point2.x = 30; point2.y = 40;
// Копируем point1 в point2. // устанавливает point2.x равным 10 // устанавливает point2.y равным 20 point2 = point1;
// 10 + 20 = 30 int a = point1.x + point1.y; // 10 + 20 = 30 int b = point2.x + point2.y;
return 0;}Указатели на структуры и оператор ->
Заголовок раздела «Указатели на структуры и оператор ->»Указатели можно заводить и на структуры — как и на любой другой тип.
Оператор -> позволяет разыменовать указатель на структуру
и затем обратиться к одному из полей.
->x можно понимать как «перейти по адресу указателя и взять переменную x оттуда».
struct Point{ int x; int y;};
int main(){ Point point; point.x = 10; point.y = 20;
Point* pointerToPoint = &point; pointerToPoint->x = 30; // Эквивалентно (*pointerToPoint).x = 30;
// 30 + 20 = 50 int a = point.x + point.y;
return 0;}Массивы структур
Заголовок раздела «Массивы структур»Массив тоже может состоять из структур.
struct Point{ int x; int y;};
int main(){ Point points[3] = { }; points[0].x = 10; points[1].x = 20; points[2].y = 30;
// 10 + 20 + 30 = 60 int a = points[0].x + points[1].x + points[2].y;
return 0;}Оператор sizeof возвращает размер типа в байтах.
Он полезен, например, при выделении памяти через malloc.
Линковщик и подробнее о функциях
Заголовок раздела «Линковщик и подробнее о функциях»Фазы компиляции вкратце
Заголовок раздела «Фазы компиляции вкратце»Компилятор — это программа, которая читает исходный код и создает исполняемый файл с машинными инструкциями, которые процессор может выполнять напрямую.
Процесс компиляции разбит на несколько фаз:
-
Препроцессинг, на котором раскрываются директивы. Например, на этой начальной фазе
#include "some_file.h"заменяется содержимымsome_file.h. -
Чтение компилятором исходных файлов и создание объектных файлов. Объектные файлы — результат компиляции одного исходного файла
.cppпосле препроцессинга (его также называют единицей трансляции). Объектные файлы содержат инструкции для функций, определенных в этом файле, и сведения о том, какие функции и глобальные переменные определены, а какие только объявлены, но не определены. -
Последняя фаза — линковка. Отдельная программа или подпрограмма в составе компилятора, называемая линковщиком, берет все объектные файлы и связывает их в один исполняемый файл.
Задача линковщика
Заголовок раздела «Задача линковщика»Задача линковщика — разрешать ссылки на функции и переменные между разными единицами трансляции.
Например, если у нас есть файл main.cpp, который объявляет функцию и вызывает её:
int f(int a);
int main(){ int b = f(10); return 0;}И другой файл, который предоставляет определение (тело) для f:
int f(int a){ return a + 1;}Работа линковщика:
- понять, что
main.cppхочет вызвать функцию с именемf, определения которой у него нет; - понять, что
f.cppпредоставляет определение функции с именемf; - осознать, что нужно связать определение из
f.cppс объявлением вmain.cpp; - связать определение функции из
f.cppс вызовом функции вmain.cpp; - создать исполняемый файл, содержащий инструкции
main.cppиf.cppвместе.
Наглядную схему происходящего см. в первом примере в linker_examples. Обязательно прочитайте и readme в этой папке.
Взглянем на картину шире — что происходит при компиляции программы: допустим, вы выполняете команду
zig c++ main.cpp f.cpp, которая:
- компилирует
main.cppотдельно, получая для него объектный файл; - компилирует
f.cppотдельно, получая для него объектный файл; - запускает линковщик на этих двух объектных файлах, создавая на выходе исполняемый файл.
А если хотите посмотреть на промежуточные объектные файлы, можно заставить zig только скомпилировать их, а затем слинковать их в исполняемый файл вручную отдельной командой:
# Compile main.cpp into main.ozig c++ -c main.cpp -o main.o
# Compile f.cpp into f.ozig c++ -c f.cpp -o f.o
# Link main.o and f.o into an executablezig c++ main.o f.o -o main.exe
# Run the executable.\main.exeОбъявления переменных, в отличие от функций, являются и определениями.
extern заставляет объявление переменной перестать быть определением.
В контексте линковщика быть глобальным/нестатическим определением означает,
что функцию или переменную можно слинковать из других файлов.
Так что extern по сути делает переменную «импортируемой» откуда-то из другого файла,
а не определяемой в текущем.
Поскольку объявления функций и так не являются определениями, extern для них избыточен,
то есть не делает ничего.
См. второй пример в linker_examples.
static-функции
Заголовок раздела «static-функции»static означает, что функция не участвует в линковке.
Фактически это значит, что использовать её можно только в единице трансляции, где она определена.
См. четвертый пример в linker_examples.
Две разные статические функции с одинаковыми именами и параметрами могут быть определены в нескольких единицах трансляции; тогда все они попадут в исполняемый файл, и каждая будет рассматриваться независимо.
inline-функции
Заголовок раздела «inline-функции»inline — это в первую очередь модификатор линковки, а не приказ встраивать инструкции.
Он разрешает определять одну и ту же функцию в нескольких единицах трансляции:
все определения должны быть идентичны, а линковщик оставляет из них одну.
Это исключение из One Definition Rule, поэтому функцию можно определять
прямо в заголовочном файле, который включается во многие .cpp-файлы.
В отличие от статических функций, inline-функция имеет внешнюю линковку: на нее можно ссылаться, ее можно вызывать из других единиц трансляции, и она может оказаться в исполняемом файле как обычная функция.
Само встраивание (inlining) — развертывание тела на месте вызова вместо настоящего вызова — от ключевого слова не зависит:
inline— лишь подсказка, которую компилятор применяет, когда захочет. Похожим образом работают макросы, только подстановка там текстовая, на уровне препроцессора. Современные компиляторы встраивают маленькие функции, когда захотят, и сinline, и без него.
См. третий пример в linker_examples.
У функций есть адреса
Заголовок раздела «У функций есть адреса»Исполняемый файл можно представлять как последовательность инструкций, где у каждой инструкции есть адрес — совсем как в уже знакомой вам обычной памяти. Единственная разница в том, что ячейки этой памяти нельзя изменить (она только для чтения) и служит она другой цели (хранит инструкции, которые выполняет процессор).
Функции, как и переменные, — это просто имена определенных адресов в исполняемом файле. Например, функция вида:
void f(){ int a; a = 5; a = a + 10; return;}будет в буквальном смысле выглядеть как отдельные строки, ставшие машинными инструкциями, записанными одна за другой
в исполняемом файле: первые инструкции (выделение некоторого места под локальную переменную) — первая инструкция,
а return; — последняя.
Для функций есть пара особых инструкций:
- Прыжок на адрес для продолжения выполнения. По сути это эквивалент вызова функции: процессор видит инструкцию прыжка и понимает, что продолжить выполнение инструкций нужно с адреса, куда он прыгает, — это может быть первый адрес функции.
- Сохранение адреса следующей инструкции на стеке (локальная память, объясню позже).
- Возврат из функции, по сути эквивалентный прыжку на адрес, сохраненный на стеке, чтобы продолжить выполнение оттуда.
Пример не помешает:
void f(){ int a; a = 5; a = a + 10; return;}
void g(){ f(); return;}Что скомпилируется во что-то вроде:
| Адрес | Инструкция |
|---|---|
| 690 | Выделить место под a на стеке |
| 691 | Записать 5 в a |
| 692 | Прибавить 10 к a |
| 693 | Возврат: прочитать адрес, сохраненный под a, и прыгнуть на него |
| 694 | Сохранить адрес 696 на стеке |
| 695 | Прыжок на адрес 690 |
| 696 | Возврат: прочитать адрес на стеке (сохраненный функцией, которая могла это вызвать), прыгнуть на него |
Адреса 690–693 соответствуют f, а 694–696 — g.
См. пример в примере функции.
Как уже говорилось, функции используют стек для хранения локальных переменных и адресов возврата (среди прочего, например аргументов). Стек — это довольно большой участок памяти, доступный программе: у него фиксированный размер, и он выделяется программе при запуске.
Стек используется для выделения временного места под такие вещи, как локальные переменные.
void f(){ int a = 5; int b = 10; // return;}
void g(){ int a = 15; f(); // return;}Стек будет выглядеть примерно так, когда мы вызываем f из g (69420 — пример адреса):
| Адрес | Переменная | Значение |
|---|---|---|
| 100 | a (из g) | 15 |
| 104 | адрес return; из g | 69420 |
| 112 | a (из f) | 5 |
| 114 | b (из f) | 10 |
Заметьте: когда f возвращается и выполнение g продолжается, стек выглядит так же,
с той лишь разницей, что локальные переменные f фактически забыты,
хотя их значения по-прежнему лежат в памяти.
Так что для g стек выглядит вот так:
| Адрес | Переменная | Значение |
|---|---|---|
| 100 | a (из g) | 15 |
| 104 | ?????????? | 69420 |
| 112 | ?????????? | 5 |
| 114 | ?????????? | 10 |
То есть старые значения все еще там — они просто стали бессмысленными без имен переменных, так что можем смело называть их мусорными данными.
Теперь предположим, что у нас есть такая программа:
void f1(){ int a = 5; int b = 10; // return;}
void f2(){ int a = 8; int b = 9; // return;}
void g(){ int a = 15; f1(); f2(); // return;}Покажу, как будет выглядеть стек в каждый момент времени:
- Когда мы вызываем
g, до вызоваf1:
| Адрес | Переменная | Значение |
|---|---|---|
| 100 | a (из g) | 15 |
| 104 | ??? | |
| 108 | ??? | |
| … | … | … |
- Когда мы находимся на
return;вf1:
| Адрес | Переменная | Значение |
|---|---|---|
| 100 | a (из g) | 15 |
| 104 | адрес f2(); в g | 69420 |
| 112 | a (из f1) | 5 |
| 116 | b (из f1) | 10 |
| … | … | … |
- Снова в
g, после возврата изf1, до вызоваf2:
| Адрес | Переменная | Значение |
|---|---|---|
| 100 | a (из g) | 15 |
| 104 | ??????? | 69420 |
| 112 | ??????? | 5 |
| 116 | ??????? | 10 |
| … | … | … |
- Когда мы находимся на
return;вf2. Заметьте, она получила ту же память под локальные переменные, перезаписав память, которую использовалаf1:
| Адрес | Переменная | Значение |
|---|---|---|
| 100 | a (из g) | 15 |
| 104 | адрес return; в g | 69423 |
| 112 | a (из f2) | 8 |
| 116 | b (из f2) | 9 |
| … | … | … |
- Снова в
g, после возврата изf2:
| Адрес | Переменная | Значение |
|---|---|---|
| 100 | a (из g) | 15 |
| 104 | ??????? | 69423 |
| 112 | ??????? | 8 |
| 116 | ??????? | 9 |
| … | … | … |
Эта демонстрация концептуальна, и полагаться на неё не стоит: компилятор волен решать, как раскладывать ваши локальные переменные и нужно ли это вообще.
Более замысловатый пример см. в примере стека 2
Рекурсия
Заголовок раздела «Рекурсия»Рекурсия — это когда функция вызывает сама себя, прямо или косвенно. Если вы понимаете стек и то, как вызываются функции, это будет легко понять.
void f(int depth){ if (depth == 2) return;
f(depth + 1);}
int main(){ f(0); return 0;}У функции f нет локальных переменных, но есть параметр depth,
который может получить место на стеке вместо локальных переменных
(для этой демонстрации будем считать, что он работает как локальная переменная).
Функция f вернется досрочно, когда depth равен 2.
Каждый раз, когда f вызывает себя, мы передаем ей большее значение depth.
Если расписать инструкции, получится примерно так:
| Адрес | Операция | Инструкция |
|---|---|---|
| 69420 (f) | _(int depth) | Прочитать локальную depth |
| 69421 | depth == 2 | Сравнить depth с 2 |
| 69422 | if (_) | Если depth в предыдущем сравнении была равна, прыгнуть на адрес 69423 |
| 69423 | return; | Возврат: прочитать адрес, сохраненный под depth, прыгнуть на него |
| 69424 | Положить адрес 69427 на стек | |
| 69425 | _(depth + 1) | Положить значение depth + 1 на стек (локальная переменная depth следующего вызова) |
| 69426 | f(_) | Прыжок на адрес 69420 |
| 69427 | return; | Возврат: прочитать адрес, сохраненный под depth, прыгнуть на него |
| 69428 (main) | Положить адрес 69431 на стек | |
| 69429 | _(0) | Положить значение 0 на стек (локальная переменная depth первого вызова) |
| 69430 | f(_) | Прыжок на адрес 69420 |
| 69431 | return 0; | Возврат: прочитать адрес, прыгнуть на него |
А стек выглядел бы так (здесь я показываю конечное состояние, а не всю последовательность):
| Адрес | Переменная | Значение |
|---|---|---|
| 100 | адрес (в main) | 69431 |
| 108 | depth из f(0) | 0 |
| 112 | адрес (в f(0)) | 69427 |
| 120 | depth из f(1) | 1 |
| 124 | адрес (в f(1)) | 69427 |
| 132 | depth из f(2) | 2 |
То есть каждый вызов функции получает собственную копию локальных переменных. Осознать это абсолютно важно.
Похожий пример — в примере памяти 4 (правда, там игнорируются адреса возврата).
Указатели на функции
Заголовок раздела «Указатели на функции»Как бывают указатели на переменные, так бывают и указатели на функции. Они позволяют вызывать функции косвенно — так же, как указатели позволяют косвенно писать в память.
// Объявляем тип указателя на функцию с именем SumFunctionType// который принимает два int и возвращает int.// Синтаксис здесь уродливейший на свете,// поэтому мы делаем typedef и забываем о нем.typedef int (*SumFunctionType)(int, int);
// Определяем функцию.// Это не обязательно должно быть определение, сгодится и объявление.int sum(int a, int b){ return a + b;}
int subtract(int a, int b){ return a - b;}
int main(){ // Берем адрес `sum` и сохраняем его в переменную. // Синтаксис тот же, что и с переменными. SumFunctionType funcPointer = ∑
// Вызываем функцию косвенно. int s = funcPointer(5, 10); // 15
// Перенацеливаем указатель на функцию на другую функцию. funcPointer = &subtract;
// Вызываем функцию косвенно. int d = funcPointer(5, 10); // -5
return 0;}