О чём лекция

Одно и то же действие не стоит переписывать в каждом месте программы — его выносят в функцию. Лекция разбирает, как функцию объявить, определить, вызвать и вернуть из неё результат. Главный вопрос — как данные попадают внутрь: по значению функция получает копию и не может изменить переменные вызывающего кода. Чтобы написать работающий Swap, нужны адреса или ссылки. В коде с занятия — ещё статические переменные, перегрузка функций и первые шаблоны.

Маршрут

  1. Зачем нужны функции, как вызвать → Функция
  2. Объявление и определение функции, сигнатура, ODR → Объявление и определение
  3. Аргументы по умолчанию
  4. return, void и что происходит при возврате
  5. Передача по значению → Передача параметров
  6. Задача обмена: копии, указатели, ссылки → Указатель, Ссылка
  7. Ссылки и константные ссылки
  8. Как передать массив → Массив
  9. Что можно вернуть по ссылке, статические переменные → Висячий указатель, Область действия
  10. Перегрузка функций → Перегрузка функций
  11. Шаблоны функций → Шаблон функции

Разделы 1–8 идут по лонгриду, 9–11 — по коду с занятия: L05 - Разбор - main.cpp.

1. Функции

Зачем давать фрагменту программы имя

Допустим, несколько раз нужно найти меньшее из двух чисел. Можно каждый раз писать условие, но одно и то же действие придётся повторять в разных местах. Или пример ближе к контестам: чтобы ввести два массива, нужно дважды скопировать цикл ввода. Удобнее один раз описать действие и затем применять его к разным данным.

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

int Min(int x, int y) {
  return x < y ? x : y;
}
  • Min — имя функции;
  • int слева — тип возвращаемого значения;
  • int x, int y — два параметра;
  • фигурные скобки ограничивают тело функции;
  • x < y ? x : y — если x < y, результат x, иначе y; return возвращает его и завершает функцию.

Общий вид:

ReturnType FunctionName(Type1 parameter1, Type2 parameter2, ...) {
  // function body
}

Имена функций на курсе пишутся с заглавной буквы (Min), см. Кодстайл курса. Это соглашение оформления, а не требование C++. Исключение — main: это специальное имя, и заменить его на Main нельзя, иначе программа не найдёт точку старта.

Как вызвать функцию

Определение само по себе тело не запускает. Чтобы выполнить функцию, её вызывают: имя и круглые скобки с исходными значениями.

#include <iostream>
 
int Min(int x, int y) {
  return x < y ? x : y;
}
 
int main() {
  int value = Min(10, 20);
  std::cout << value << '\n';  // 10
  return 0;
}

При вызове Min(10, 20) параметр x получает 10, y — 20, выполняется тело, и возвращённое число инициализирует value.

  • Выражения в скобках вызова — аргументы (10, 20).
  • Переменные в заголовке функции — параметры (x, y).

Аргументом может быть любое выражение:

int a = 10;
int b = 20;
 
int first = Min(a, b);
int second = Min(a + 5, b - 3);
std::cout << Min(10, 20) << '\n';

Вызов Min — выражение, значение которого и есть возвращённое число. Поэтому результат можно сразу отправить в поток, без отдельной переменной.

Скобки в определении и в вызове играют разные роли. В Min(int x, int y) это объявления параметров с типами. В Min(10, 20) это аргументы, и типы перед ними заново не пишут.

У main особая роль: управление в неё передаёт среда выполнения. Вызывать main из своего кода стандарт запрещает. То, что конкретный компилятор такой код пропускает без строгой проверки, не делает программу корректной.

Не из лекции

GCC 16.2 молча компилирует int main() { return main(); }. Нарушение он показывает только с -pedantic: ISO C++ forbids taking address of function '::main'.

2. Объявление и определение функции

Что нужно знать компилятору в месте вызова

Компилятору не нужно видеть тело функции, чтобы её вызвать. Достаточно заранее сообщить, что функция существует и как к ней обращаться. Это объявление функции:

#include <iostream>
 
int Min(int x, int y);  // объявление: тела нет, в конце точка с запятой
 
int main() {
  std::cout << Min(10, 20) << '\n';
  return 0;
}
 
int Min(int x, int y) {  // определение: есть тело
  return x < y ? x : y;
}

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

Если вызов стоит выше всех объявлений, компилятор не станет искать определение в оставшейся части файла:

int main() {
  return Min(10, 20);  // CE: Min is not declared here
}
 
int Min(int x, int y) {
  return x < y ? x : y;
}

Имя должно быть известно в той точке, где его используют. Чинится это объявлением перед main или переносом определения выше. Та же история, что с переменными и extern в L02, — см. Объявление и определение.

Имена параметров в объявлении

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

int Min(int x, int y);
int Min(int, int);
int Min(int x, int);

Для проверки вызова компилятору нужно знать только то, что функция принимает два int. Имена в объявлении пишут для читателя.

В определении имена нужны, если к параметрам обращаются в теле. Аргументы в вызывающем коде могут называться как угодно: заводить в main переменные x и y, чтобы вызвать Min, не нужно.

Сигнатура функции

Для обычной свободной функции сигнатуру задают имя, список типов параметров и пространство имён, которому функция принадлежит. В наших примерах достаточно смотреть на имя и типы: Min(int, int).

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

int Min(int, int);
float Min(int, int);  // CE: conflicting return types

Имена параметров тоже не входят: int Min(int x, int y); и int Min(int a, int b); объявляют одну функцию.

В определении к параметру, передаваемому по значению, можно добавить const. Функция от этого не меняется:

int Min(int x, int y);
 
int Min(int x, const int y) {  // всё та же Min(int, int)
  return x < y ? x : y;
}

const здесь запрещает менять локальный параметр y внутри тела: y += 1; не скомпилируется. На то, как функцию вызывают, этот запрет не влияет.

Объявлений может быть несколько, определение — одно

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

int Min(int x, int y) {
  return x < y ? x : y;
}
 
int Min(int x, int y) {  // CE: redefinition
  return x > y ? x : y;
}

Компилятор не выбирает «последнюю версию» и не решает, какая реализация лучше: второй раз определяется та же функция. Это правило одного определения (One Definition Rule, ODR). Для обычных функций в одном файле оно выглядит просто: объявления можно повторять, определение с телом — одно.

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

3. Аргументы по умолчанию

Как разрешить вызов с меньшим числом аргументов

Min(5) не скомпилируется: неизвестно, что передать во второй параметр. Можно заранее указать аргумент по умолчанию (default argument):

int Min(int x, int y = 1);
 
int main() {
  int first = Min(5);        // x = 5, y = 1 → 1
  int second = Min(10, 20);  // по умолчанию не нужно → 10
  return 0;
}
 
int Min(int x, int y) {
  return x < y ? x : y;
}

В определении = 1 не повторяется: значение уже есть в объявлении, видимом перед вызовами. Тело работает с двумя параметрами и не знает, как вызывающий код задал их значения.

Значения по умолчанию можно дать обоим параметрам:

int Min(int x = 0, int y = 1);
 
std::cout << Min() << '\n';        // 0  (0 и 1)
std::cout << Min(5) << '\n';       // 1  (5 и 1)
std::cout << Min(10, 20) << '\n';  // 10 (10 и 20)

Какие аргументы можно опускать

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

int First(int x, int y);
int Second(int x, int y = 1);
int Third(int x = 0, int y = 1);
 
int Wrong(int x = 0, int y);  // CE

Wrong(5) в любом случае отдал бы 5 в первый параметр. Указать только y, оставив x по умолчанию, нельзя. Пустая позиция — тоже не способ:

Min(, 20);  // CE: invalid syntax

Удобнее всего записывать значения по умолчанию в предварительном объявлении, которое видит вызывающий код. Если перед main стоит только int Min(int, int);, а = 1 написано в определении ниже, то вызов Min(5) в main некорректен: в этой точке значение по умолчанию ещё неизвестно. Повторить тот же аргумент по умолчанию в следующем объявлении или в определении нельзя, даже с тем же значением.

Не из лекции

Ошибки GCC 16.2 для этих случаев: default argument missing for parameter 2 of 'int Wrong(int, int)' и default argument given for parameter 2 of 'int Min(int, int)' (повтор = 1 в определении).

Значение по умолчанию не создаёт новую функцию

int Min(int x, int y);
int Min(int, int);
int Min(int x, int);
int Min(int x = 0, int y = 1);

Последняя строка не определяет отдельных функций Min() и Min(int). Функция по-прежнему одна, с двумя параметрами. Просто там, где видно объявление с умолчаниями, часть аргументов можно не писать. Аргументы по умолчанию не входят в сигнатуру: тело всегда получает оба значения и не различает вызовы Min() и Min(0, 1).

4. Возврат из функции

Что делает return

return выполняет два действия: завершает текущий вызов и, если нужно, передаёт результат в место вызова. Это не вывод на экран:

int result = Min(10, 20);  // 10 записывается в result, на экране ничего

return может быть несколько:

float Max(float x, float y) {
  if (x < y) {
    return y;
  }
  return x;
}

Если условие истинно, return y; сразу завершает вызов, до return x; выполнение не доходит. За один вызов срабатывает только тот return, до которого управление дошло первым.

Нельзя забывать результат на одном из путей

float Max(float x, float y) {
  if (x < y) {
    return y;
  }
}  // UB if execution reaches this point

При вызове Max(10, 4) условие ложно, и выполнение доходит до закрывающей скобки, хотя функция обязана вернуть float. Достижение конца функции с не-void типом без return — Неопределённое поведение. Ноль, последнее вычисленное число или «пустое» значение никто не гарантирует. Предупреждение компилятора здесь нужно исправлять.

В эксперименте на лекции программа упала с Illegal instruction. Это лишь одно из возможных проявлений: на гарантированный сбой рассчитывать тоже нельзя.

Проверять нужно все пути выполнения: каждый, который нормально завершает вызов, должен вести к return. У main есть особое правило: дойти до конца её тела — то же, что return 0;. На обычные функции оно не распространяется.

Не из лекции

Проверено на GCC 16.2: с -Wall компилятор предупреждает control reaches end of non-void function [-Wreturn-type]. Программа с Max(10, 4) завершилась с кодом 132, это сигнал SIGILL, то есть тот же Illegal instruction, что и на лекции. GCC ставит в конец такой функции инструкцию ловушки (ud2 на x86), но это решение компилятора, а не гарантия языка.

Функции без возвращаемого значения

Если функция нужна ради действия, а результат вызывающему коду не нужен, возвращаемый тип — void:

#include <iostream>
 
void PrintHelloWorld();
 
int main() {
  PrintHelloWorld();  // Hello, world!
  return 0;
}
 
void PrintHelloWorld() {
  std::cout << "Hello, world!\n";
}

Параметров нет — скобки пустые и в объявлении, и в определении, и в вызове. Убрать их из вызова нельзя: именно PrintHelloWorld() просит выполнить функцию.

void-функция может просто дойти до конца тела. Выйти раньше можно через return; без выражения. Вернуть число нельзя.

void не значит «функция ничего не делает»: приветствие напечатано, полезное действие выполнено. Нет только значения, которым можно было бы инициализировать переменную.

Как управление возвращается в место вызова

При выполнении короткий Max(10, 4) превращается в несколько действий: передать аргументы, выполнить тело, получить результат и продолжить вызывающий код.

  • В обычной модели машинного вызова аргументы передаются через регистры процессора и/или память. Для результата тоже есть место, заданное соглашениями платформы.
  • Кроме результата нужен адрес возврата: откуда продолжить вызывающий код. Это разные вещи: возвращённое 10 идёт в вычисления, адрес нужен для передачи управления.
  • Данные, которые лежат в стеке ради одного вызова, образуют кадр стека вызовов (stack frame): адрес возврата, аргументы, локальные переменные, сохранённые регистры. При вложенном вызове поверх текущего кадра создаётся новый, при возврате он освобождается.

Это упрощённая модель. Часть данных может передаваться через регистры, состав кадра зависит от платформы и оптимизаций, а иногда кадр вообще не создаётся. Поэтому отсутствие return нельзя объяснять «гарантированным чтением какого-то регистра»: правило языка здесь одно — неопределённое поведение.

Стек вызовов в АиСД уже встречался: глубокая Рекурсия переполняет его.

5. Передача параметров по значению

Параметр — отдельная переменная

Все параметры до сих пор передавались по значению (pass by value): функция получает собственные переменные, инициализированные значениями аргументов. Подробно о способах передачи — Передача параметров.

float Max(float x, float y) {
  x += 1;
  if (x < y) {
    return y;
  }
  return x;
}
 
float y = Max(10, 4);
std::cout << y << '\n';  // 11

x и y получают 10 и 4, локальный x становится 11, условие 11 < 4 ложно, возвращается 11. Целые 10 и 4 при этом преобразуются к типу параметров float.

Имя y в вызывающем коде и параметр y в Max — разные переменные. В момент вызова параметр y равен 4, а переменная, которую инициализируем результатом, получает 11.

Что происходит с исходными аргументами

float a = 10;
float b = 4;
 
float result = Max(a, b);
 
std::cout << a << '\n';       // 10
std::cout << b << '\n';       // 4
std::cout << result << '\n';  // 11

Значение a скопировано в x, x += 1 изменило копию. У a по-прежнему 10. Возврат тоже не меняет аргументы, он только даёт значение выражению вызова. Чтобы записать результат в a, нужно отдельное присваивание: a = Max(a, b);.

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

6. Как изменить переменные вызывающей функции

Задача обмена значений

float z = 1.0f;
float v = 2.0f;

Нужна функция, после которой в z окажется 2, а в v — 1. Результат выражается изменением существующих объектов, поэтому возвращаемый тип — void.

В стандартной библиотеке такая функция уже есть — std::swap из заголовка <utility>:

std::swap(z, v);
std::cout << z << ' ' << v << '\n';  // 2 1

Напишем свой аналог. Сам обмен делается через временную переменную:

float tmp = z;
z = v;
v = tmp;

Двумя присваиваниями не обойтись:

z = v;  // обе переменные уже равны 2
v = z;  // старое значение z потеряно

Почему DummySwap ничего не меняет снаружи

void DummySwap(float lhs, float rhs) {
  float tmp = lhs;
  lhs = rhs;
  rhs = tmp;
}
 
float z = 1.0f;
float v = 2.0f;
 
DummySwap(z, v);
std::cout << z << ' ' << v << '\n';  // 1 2

Алгоритм верный, но меняет не те объекты. При входе создаются lhs и rhs, в них копируются z и v — теперь объектов четыре. Обмен происходит между lhs и rhs, исходные z и v никто не трогал. Функция завершилась, параметры исчезли, снаружи прежняя пара значений.

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

Функции нужно дать доступ к самим переменным. Уже знакомый способ — передать их адреса.

7. Передача адресов в функцию

SwapByPointer: меняем объекты по адресам

void SwapByPointer(float* lhs, float* rhs) {
  float tmp = *lhs;  // значение объекта по адресу lhs
  *lhs = *rhs;       // записываем в объект по адресу lhs
  *rhs = tmp;
}
 
float z = 1.0f;
float v = 2.0f;
 
SwapByPointer(&z, &v);
std::cout << z << ' ' << v << '\n';  // 2 1

lhs и rhs — указатели. *lhs — объект типа float по адресу lhs: из него можно читать, в него можно писать. При вызове &z попадает в lhs, &v — в rhs, поэтому запись через *lhs меняет z. Временная переменная хранит значение z, а не адрес.

Важно различать две операции:

*lhs = *rhs;  // assign to the pointed-to object
lhs = rhs;    // assign to the local pointer

Первая строка записывает одно float в другое. Вторая заставляет локальный указатель lhs смотреть туда же, куда rhs. Оба указателя ведут к одному объекту, но обмена значений не произошло.

Указатель тоже передаётся по значению

Правило копирования параметров никуда не делось: в SwapByPointer копируются значения — теперь это адреса. Присвоить локальному указателю другой адрес — указатель снаружи не изменится. Но копия адреса позволяет найти тот же объект и изменить его:

void SetZero(float* ptr) {
  *ptr = 0.0f;    // меняет объект снаружи
  ptr = nullptr;  // меняет только локальную копию указателя
}
 
int main() {
  float value = 7.0f;
  float* address = &value;
  SetZero(address);
 
  std::cout << value << '\n';                // 0
  std::cout << (address == &value) << '\n';  // 1
}

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

SwapByPointer сразу разыменовывает указатели, поэтому nullptr ей передавать нельзя. Параметр-указатель сам не проверяет, что адрес годится для обращения к объекту.

Напоминание: что запрещает const у указателя

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

void SwapByPointer(float* const lhs, float* const rhs) {
  float tmp = *lhs;
  *lhs = *rhs;
  *rhs = tmp;
}

const после звёздочки относится к самому указателю: другой адрес параметру присвоить нельзя, объект по адресу менять можно.

void Example(float* const ptr) {
  *ptr = 5.0f;    // OK
  ptr = nullptr;  // CE: ptr itself is const
}

Не путать с другой расстановкой:

float value = 1.0f;
float other = 2.0f;
 
float* const fixed = &value;
*fixed = 3.0f;  // OK
 
const float* read_only = &value;
read_only = &other;  // OK
// *read_only = 3.0f; // CE

float* const — константен указатель: адрес менять нельзя, объект можно. const float* — через указатель нельзя менять объект, но сам указатель можно перенаправить. const отвечает за конкретную часть типа. Подробнее — Указатель и const и constexpr.

И ещё одно следствие передачи по значению. Объявление void SwapByPointer(float* lhs, float* rhs); и определение с float* const относятся к одной функции: константность локальной копии не меняет того, что требуется от вызывающего кода. А вот замена float* на const float* уже меняет тип параметра — это про доступ к самому объекту.

8. Ссылки

Другое имя того же объекта

Указатели решили задачу, но пришлось явно брать адреса при вызове и явно разыменовывать внутри функции. Для прямого обращения к существующему объекту в C++ есть ещё один механизм — ссылки (references).

float value = 1.0f;
float& reference = value;
 
reference = 5.0f;
std::cout << value << '\n';  // 5

float& — тип «ссылка на float». При инициализации reference связывается с value, и дальше обращение через reference — обращение к value. Удобно думать о ссылке как о ещё одном имени переменной.

Это не копия:

float value = 1.0f;
float copy = value;
float& reference = value;
 
copy = 7.0f;
std::cout << value << '\n';  // 1
reference = 9.0f;
std::cout << value << '\n';  // 9

& в объявлении ссылки — часть типа. & в выражении &value — взятие адреса. Выглядят одинаково, делают разное. Чтобы инициализировать ссылку на value, пишут само имя value, а не адрес.

Инициализация и постоянная привязка

Ссылку нужно сразу с чем-то связать:

float value = 1.0f;
float& good = value;  // OK
float& bad;           // CE: a reference needs an initializer

У указателя бывает nullptr. У ссылки состояния «пока ни с чем не связана» нет.

После инициализации ссылку нельзя перепривязать к другому объекту. Значение объекта при этом не заморожено:

float z = 1.0f;
float v = 2.0f;
float& reference = z;
 
reference = v;
std::cout << z << ' ' << v << '\n';  // 2 2
 
reference = 8.0f;
std::cout << z << ' ' << v << '\n';  // 8 2

reference = v; не меняет привязку — оно присваивает значение v объекту z. Поэтому следующее присваивание через ссылку опять меняет z. Инициализация устанавливает связь, присваивание пишет в уже связанный объект. Один и тот же символ = стоит в двух разных конструкциях.

Ссылка не создаёт второй экземпляр объекта и не продлевает его жизнь. При каждом использовании ссылки объект должен ещё существовать — для локальных примеров это легко обеспечить: сначала переменная, потом ссылка, обе в одном блоке. Что бывает, если нарушить, — раздел 11.

Swap: параметры по ссылке

void Swap(float& lhs, float& rhs) {
  float tmp = lhs;
  lhs = rhs;
  rhs = tmp;
}
 
float z = 1.0f;
float v = 2.0f;
 
Swap(z, v);
std::cout << z << ' ' << v << '\n';  // 2 1

Копий нет: на время вызова lhs связывается с z, rhs — с v, и присваивания через параметры меняют эти объекты. Адреса явно не берём, разыменований нет. Тело Swap буква в букву совпадает с телом DummySwap, а последствия вызова другие.

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

Обычная изменяемая ссылка должна связываться с подходящим изменяемым объектом. Swap(z, v) допустим, Swap(1.0f, 2.0f) — нет: литералы не дают двух переменных, которые можно обменять.

9. Константные ссылки

Запрет изменения через ссылку

Иногда к исходному объекту нужно обратиться, но менять его не нужно. Для этого — ссылка на константный тип:

float z = 1.0f;
const float& rf = z;
 
std::cout << rf << '\n';  // 1
// rf = 7.0f;             // CE
 
z += 1.0f;
std::cout << rf << '\n';  // 2

rf связана с z, копии нет. Но тип const float& запрещает менять объект через эту ссылку. Сама z объявлена без const, её можно менять по имени z, и rf увидит новое значение.

Константная ссылка не замораживает объект. Она запрещает менять его через эту ссылку.

Наоборот нельзя:

const int value = 10;
const int& good = value;  // OK
int& bad = value;         // CE

Через int& можно присваивать. Будь такая привязка разрешена, через неё изменился бы объект, объявленный const int. Отбросить константность при привязке ссылки нельзя. Привязать const int& к обычному int можно: мы лишь ограничиваем действия через новое имя.

Параметр const int&

Ссылочный параметр даёт доступ к исходному объекту, поэтому случайное присваивание в теле изменит аргумент. Если бы первый параметр Max был float&, x += 1; меняло бы переданную переменную. С const float& эта строка не скомпилируется.

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

void PrintX(const int& x) {
  std::cout << "x=" << x << '\n';
}
 
int x = 0;
PrintX(x);  // x=0

Совпадение имён ничего не значит: функция не ищет внешнюю переменную x, связь устанавливается через аргумент. Параметр можно переименовать, смысл останется прежним:

void PrintX(const int& value) {
  std::cout << "x=" << value << '\n';
}

Это альтернативная запись того же определения, а не второе определение рядом.

int здесь можно было передать и по значению. Константная ссылка взята, чтобы разобрать механизм. Правило «любой аргумент — по константной ссылке» из этого не следует.

Но как правило стоит передавать по ссылке любой объект, размер которого превышает два машинных слова (16 байт на большинстве систем).

Почему PrintX(10) допустим

PrintX(10);  // x=10

Для int& такой аргумент не подошёл бы. Но const int& может быть связана с временным объектом: при вызове создаётся временный объект со значением 10, он живёт, пока функция читает значение, и уничтожается после завершения всего выражения вызова.

Это первый шаг к категориям выражений, см. Категории значения. Выражение из имени переменной (x) обозначает существующий объект — lvalue. Литерал 10 — prvalue. Следствие для текущей темы: int& к такому литералу не привязывается, а const int& позволяет передать его функции для чтения.

PrintX(x) и PrintX(10) вызывают одну и ту же функцию. Различается только то, с каким объектом связывается параметр.

10. Как передать массив

Один указатель на первый элемент не говорит функции, сколько элементов можно смотреть. Варианты:

Начало и количество элементов:

void PrintArray(const int* data, int size);
 
int values[] = {3, 1, 4};
PrintArray(values, 3);

В месте вызова имя массива превращается в указатель на нулевой элемент, см. Массив. Указатель на динамический массив передаётся так же. Размер передаётся отдельно и должен соответствовать доступной части массива. Тип const int* — потому что для вывода менять элементы не нужно.

Указатели на начало и конец диапазона:

void PrintRange(const int* begin, const int* end);
 
int values[] = {3, 1, 4};
PrintRange(values, values + 3);

Второй указатель обозначает позицию сразу после последнего элемента. Получается полуинтервал [begin, end): начало включено, конец нет. Значение values + 3 допустимо как граница, но разыменовывать его нельзя.

Так устроены и стандартные алгоритмы: std::sort из <algorithm> принимает два указателя, и std::sort(values, values + 3) сортирует три элемента, где второй указатель — граница, не включённая в диапазон.

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

В лонгриде даны только объявления — тела предлагается написать самостоятельно.

Не из лекции

Один из вариантов тел, в кодстайле курса:

void PrintArray(const int* data, int size) {
  for (int i = 0; i < size; ++i) {
    std::cout << data[i] << ' ';
  }
  std::cout << '\n';
}
 
void PrintRange(const int* begin, const int* end) {
  for (const int* it = begin; it != end; ++it) {
    std::cout << *it << ' ';
  }
  std::cout << '\n';
}

Условие it != end проверяется до чтения *it, поэтому end никогда не разыменовывается — и пустой диапазон PrintRange(values, values) просто не заходит в цикл. Если бы граница указывала на последний элемент, а не за него, пустой диапазон записать было бы нечем.

11. Что можно вернуть по ссылке. Статические переменные

Этот и следующие разделы есть только в коде с занятия, пояснения к нему даны короткими комментариями. Полный файл — L05 - Разбор - main.cpp.

Возврат ссылки

Функция может возвращать ссылку — тип int&. Комментарий в коде перечисляет, на что можно вернуть ссылку или указатель:

  • на объект в динамической памяти;
  • на глобальную переменную — любой объект со статической областью действия;
  • на переменную, которая была передана в функцию как параметр по ссылке.

Сама функция F в коде — как раз пример того, чего делать нельзя:

int& F() {
  int x = 0;
  return x;  // ссылка на локальную переменную
}

Не из лекции

Локальная x живёт до конца вызова F. Вызывающий код получит ссылку на уже уничтоженный объект — висячую ссылку, и любое обращение через неё будет неопределённым поведением. Это то же, что Висячий указатель, только со ссылкой. GCC 16.2 предупреждает: reference to local variable 'x' returned [-Wreturn-local-addr]. В main с занятия F() не вызывается.

Статические переменные

Из комментария в коде, статические переменные:

  • имеют статическую область действия (static storage duration);
  • инициализируются один раз;
  • сохраняют значение между вызовами;
  • по умолчанию инициализируются нулями.
int& G() {
  static int x;  // = 0
  return ++x;
}
 
std::cout << G() << '\n';  // 1
std::cout << G() << '\n';  // 2
std::cout << G() << '\n';  // 3
G() = 10;
std::cout << G() << '\n';  // 11

x объявлена внутри функции, но живёт всю программу — поэтому ссылку на неё вернуть можно (второй пункт списка выше). Каждый вызов увеличивает одну и ту же x. G() = 10; сначала увеличивает x до 4, а затем через возвращённую ссылку записывает в неё 10. Следующий вызов даёт 11.

static можно объявить и во вложенном блоке:

void H(int x) {
  std::cout << ++x << ' ';
  {
    static int y;
    int z = x;
    std::cout << ++y << ' ' << ++z << '\n';
  }
}
 
H(1);  // 2 1 3
H(2);  // 3 2 4
H(3);  // 4 3 5

x и z создаются заново при каждом вызове, y — одна на все вызовы и растёт 1, 2, 3. Подробнее о времени жизни — Область действия.

Не из лекции

«Инициализируется один раз» значит: при первом прохождении управления через объявление, а не при каждом вызове. Обнуление по умолчанию касается именно статических переменных. Локальная int x; без static остаётся неинициализированной, и чтение из неё — UB, см. Инициализация переменных.

12. Перегрузка функций

В коде с занятия три функции Swap и четыре PrintValue. Комментарий: функции можно перегружать по количеству аргументов и по типам аргументов. Это Перегрузка функций (function overloading): одно имя, разные сигнатуры.

void Swap(int& lhs, int& rhs) { /* ... */ }
void Swap(float& lhs, float& rhs) { /* ... */ }
void Swap(float& x) {}
 
void PrintValue(double x);
void PrintValue(int x);
void PrintValue(long x);
void PrintValue(long x, long y);

Правила выбора, как они записаны в комментарии:

  • при вызове выбирается наиболее подходящая по параметрам версия, требующая наименьшего числа преобразований (conversion);
  • расширение (promotion) — не преобразование; float → long double и double → long double — преобразования;
  • при выборе перегрузки константность самого параметра игнорируется: у int* const константность на параметре — игнорируется, у const int* константность на объекте, на который указываем, — нет.

Последнее — то же правило, что в разделе 7 про SwapByPointer: const на копии параметра не меняет сигнатуру.

float y = 0;
int x = 0;
PrintValue(y);     // PrintValue(double x): value=0
PrintValue(x);     // PrintValue(int x): value=0
PrintValue(y, y);  // PrintValue(long x, long y): x=0 y=0

float → double — расширение, поэтому выбирается double. Комментарий в коде: если бы версии с double не было, было бы непонятно, какую перегрузку выбрать.

Ошибка в комментарии кода: int → long — не расширение

В main.cpp сказано: «расширение (promotion) — не преобразование (int -> long, float -> double)». Про float → double верно, про int → long — нет: это целочисленное преобразование (integral conversion), того же ранга, что int → double. Расширение для целых — это только малые типы (bool, char, short) в int, как в Арифметические преобразования типов.

Проверка на GCC 16.2: при перегрузках P(long) и P(double) вызов P(i) с int i — ошибка call of overloaded 'P(int&)' is ambiguous. Будь int → long расширением, выбрался бы long. В примере с занятия PrintValue(x) выбирает int просто потому, что это точное совпадение.

Не из лекции

Чуть точнее, чем «наименьшее число преобразований»: каждый аргумент оценивается по рангу — точное совпадение лучше расширения, расширение лучше преобразования. Побеждает перегрузка, которая по каждому аргументу не хуже остальных и хотя бы по одному лучше. Если такой нет — ошибка неоднозначности.

  • PrintValue(y) с float y: double — расширение, int и long — преобразования. Без версии с double все три оставшиеся были бы преобразованиями, и вызов стал бы неоднозначным (проверено: ошибка для набора int/long).
  • PrintValue(y, y): двухпараметрическая версия одна, float → long допустимо, выводится x=0 y=0.
  • Swap(v) и Swap(z) с одним float вызывают пустую Swap(float&): она ничего не делает, и после них z и v остаются обменянными одним Swap(z, v) — 2 1.

13. Шаблоны функций

Три Swap отличаются только типом. Вместо того чтобы писать версию на каждый тип, пишут шаблон функции (function template):

template <typename T>
void Swap(T& lhs, T& rhs) {
  T tmp = lhs;
  lhs = rhs;
  rhs = tmp;
}
 
double lhs = 2, rhs = 1;
Swap(lhs, rhs);  // T = double
std::cout << lhs << ' ' << rhs << '\n';  // 1 2

T — параметр шаблона: компилятор подставляет вместо него тип, выведенный из аргументов. Для double отдельной Swap в файле нет, работает шаблон.

Неточность в комментарии кода: шаблоны и препроцессор

В main.cpp написано: «шаблоны на этапе препроцессинга превращаются в перегрузки функций». Идея «шаблон порождает перегрузки» верна, этап — нет. Препроцессор только раскрывает #include и #define, см. Компиляция и сборка. Шаблон он не трогает: в выводе g++ -E main.cpp строка template <typename T> остаётся как есть. Конкретные функции из шаблона (инстанцирование, instantiation) делает компилятор — когда видит вызов и выводит типы.

Правила вывода из комментария:

  • типы выводятся для аргументов независимо;
  • при выведении по значению (параметр T, а не T&):
    1. отбрасываются const и volatile;
    2. ссылочность аргумента теряется;
    3. массив низводится до указателя на первый элемент;
    4. функция низводится до указателя на функцию.
template <typename T, typename U>
void PrintValues(T x, U y) {
  std::cout << "x=" << x << ' ' << "y=" << y << '\n';
}
 
int x = 0;
int& rx = x;
PrintValues(rx, 0.0);         // T = int (ссылка потеряна), U = double: x=0 y=0
PrintValues<float>(1, 0.0);   // T = float задан явно, U = double: x=1 y=0

Тип можно указать явно в угловых скобках: PrintValues<float> задаёт T, а U по-прежнему выводится из второго аргумента.

Не из лекции

  • В комментарии стоит вопрос «volatile (что это?)». volatile — квалификатор, как const: он говорит компилятору, что объект может измениться в обход программы (например, регистр устройства), поэтому каждое чтение и запись нужно выполнять честно, без оптимизаций. В обычном коде курса не встречается.
  • Проверено на GCC 16.2: const int cx даёт T = int, массив int[3] — T = int*.
  • Почему Swap(z, v) с float вызвал обычную Swap(float&, float&), а не шаблон: при равных преобразованиях нешаблонная функция предпочтительнее шаблона. Для double нешаблонной версии нет — работает шаблон.
  • «Независимо» значит: каждый аргумент выводит T сам, и выводы должны совпасть. Swap(a, c) с float a и double c — ошибка deduced conflicting types for parameter 'T'.

14. Задачи из лекции

Лонгрид предлагает объяснить результат без запуска. Ответы ниже проверены на GCC 16.2.

Копия, указатель или ссылка?

int a = 1;
int b = a;
int* p = &a;
int& r = a;
 
b = 2;
*p = 3;
r = 4;
std::cout << a << ' ' << b << '\n';

Не из лекции — ответ

4 2. b — копия, b = 2 меняет только b. *p = 3 и r = 4 оба пишут в a, последнее присваивание — 4.

Можно ли перепривязать ссылку?

int a = 10;
int b = 20;
int& r = a;
int* p = &a;
 
r = b;
p = &b;
r = 30;
std::cout << a << ' ' << b << '\n';

Не из лекции — ответ

30 20. r = b записывает 20 в a, привязка не меняется. p = &b перенаправляет только указатель. r = 30 по-прежнему пишет в a, а не туда, куда теперь смотрит p: ссылка и указатель — независимые объекты.

Что именно защищает const? Какие строки допустимы (каждая сама по себе)?

int x = 1;
const int y = 2;
 
int& a = x;
int& b = y;
const int& c = x;
const int& d = 10;
int* const p = &x;
const int* q = &x;
 
c = 3;
x = 3;
*p = 4;
p = nullptr;
q = &y;
*q = 4;

Не из лекции — ответ

СтрокаИтогПочему
int& a = x;OK
int& b = y;CEотбросила бы const у y
const int& c = x;OKограничиваем доступ — можно
const int& d = 10;OKconst& привязывается к временному объекту
int* const p = &x;OK
const int* q = &x;OK
c = 3;CEзапись через константную ссылку
x = 3;OKx сам не const
*p = 4;OKконстантен указатель, не объект
p = nullptr;CEp — константный указатель
q = &y;OKq можно перенаправить
*q = 4;CEзапись через указатель на константу

Обмен с самим собой и пустой диапазон. Что произойдёт при Swap(z, z)? Что выведет PrintRange(values, values)?

Не из лекции — ответ

Swap(z, z): lhs и rhs — две ссылки на один z. tmp = z (1), z = z (1), z = tmp (1). Значение не меняется, ошибки нет. Для этой версии Swap случай безопасен; будь обмен написан «без временной переменной» через арифметику, z мог бы обнулиться.

PrintRange(values, values) ничего не выводит: begin == end, условие цикла ложно сразу, ни один элемент не читается — см. тела в разделе 10.

Грабли

  • Вызов выше объявления — CE. Компилятор не ищет определение ниже по файлу.
  • Возвращаемый тип не входит в сигнатуру: две функции, различающиеся только им, — ошибка.
  • Аргумент по умолчанию — один раз, в объявлении, видимом при вызове. В определении его не повторяют.
  • Не-void функция без return на каком-то пути — UB, а не ноль. Исключение только у main.
  • Присваивание параметру по значению не меняет аргумент. DummySwap ничего не обменивает.
  • lhs = rhs для указателей — не обмен, а перенаправление локального указателя.
  • Ссылку нельзя перепривязать: r = b пишет значение b в объект r.
  • int& не принимает литерал, const int& — принимает.
  • Константная ссылка не замораживает объект, она запрещает изменение только через себя.
  • Нельзя возвращать ссылку на локальную переменную — висячая ссылка.
  • static внутри функции — одна переменная на все вызовы, а не новая каждый раз.
  • int → long — преобразование, не расширение: в перегрузках это даёт неоднозначность.

После этой лекции должно получаться

  • объяснить разницу между параметром и аргументом, объявлением и определением;
  • сказать, что входит в сигнатуру функции и что нет;
  • объяснить, почему int Wrong(int x = 0, int y); не компилируется;
  • найти путь, на котором функция не возвращает значение;
  • объяснить, почему DummySwap не работает, и написать Swap через указатели и через ссылки;
  • отличить reference = v от перепривязки ссылки;
  • объяснить, почему PrintX(10) компилируется с const int& и не компилируется с int&;
  • передать массив в функцию двумя способами и объяснить полуинтервал [begin, end);
  • предсказать вывод G() и H() со статическими переменными;
  • объяснить, какую перегрузку PrintValue выберет компилятор и почему;
  • сказать, какой T выведется для PrintValues(rx, 0.0).