Программа курса← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Функции, перегрузка и почему макросы – зло

Функция – способ дать имя куску работы. В C++ у неё несколько особенностей, которых нет в языках попроще: перегрузка, три способа передать аргумент и препроцессорный двойник, который выглядит как функция, но ею не является.

Объявление и определение

int Power(int base, int exp);        // объявление: как вызывать

int Power(int base, int exp) {       // определение: что делает
    int result = 1;
    for (int i = 0; i < exp; ++i) {
        result *= base;
    }

    return result;
}

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

Функция без возвращаемого значения объявляется как void. Функция с типом, отличным от void, обязана вернуть значение по всем путям выполнения – пропущенный return в одной из веток даёт неопределённое поведение, а компилятор об этом лишь предупреждает.

Три способа передать аргумент

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

По значению – функция получает копию:

void Increment(int x) { x += 1; }    // снаружи ничего не изменится

По ссылке – функция получает псевдоним оригинала:

void Increment(int& x) { x += 1; }   // изменит переменную вызывающего

По константной ссылке – псевдоним, но менять нельзя:

void Print(const std::string& text); // без копии и без права изменить

Для int копия ничего не стоит. Для строки на мегабайт или вектора на миллион элементов передача по значению означает полное копирование при каждом вызове.

Что передаём Как Почему
Встроенный тип (int, double, указатель) по значению копия дешевле ссылки
Объект, который читаем const T& без копии, без права изменить
Объект, который меняем T& изменения видны вызывающему
Объект, который забираем себе по значению + std::move вместо копии – перенос

Практическое правило: const T& по умолчанию для всего, что больше указателя. Если в сигнатуре видно & без const – это сигнал читателю: «функция изменит ваш объект».

Значения по умолчанию

void Connect(const std::string& host, int port = 8080, bool secure = false);

Connect("example.com");              // port = 8080, secure = false
Connect("example.com", 9000);        // secure = false

Аргументы со значениями по умолчанию идут последними – иначе непонятно, какой из них пропущен. Указывать значение нужно в объявлении, в заголовке; повторять его в определении нельзя.

Перегрузка

Несколько функций с одним именем и разными параметрами:

void Print(int value);
void Print(double value);
void Print(const std::string& value);

Print(42);        // int
Print(3.14);      // double
Print("hello");   // const std::string&

Компилятор выбирает по типам аргументов – это разрешение перегрузки, и приоритет такой: точное совпадение, затем продвижение типа (float → double), затем стандартное преобразование (int → double), затем пользовательское.

Различать перегрузки только по возвращаемому типу нельзя: в момент выбора компилятор ещё не знает, куда попадёт результат.

int GetValue();
double GetValue();   // ошибка: сигнатуры не различаются

Отсюда искажение имён (name mangling) в сообщениях компоновщика: чтобы Print(int) и Print(double) не совпали в объектном файле, типы аргументов кодируются прямо в имени. Из-за этого же C++ нельзя напрямую вызвать из C – там перегрузок нет и имена не искажаются. Для этого пишут extern "C".

Макросы

Макрос – текстовая замена, которую делает препроцессор до всякой компиляции.

#define MAX_USERS 100
#define SQR(x) x * x

Выглядит как константа и как функция, но не является ни тем, ни другим. И именно это создаёт проблемы.

Проблема первая: приоритет операций

#define SQR(x) x * x

int a = SQR(3 + 0);      // подставится как 3 + 0 * 3 + 0, то есть 3

Функция вычислила бы аргумент до вызова. Препроцессор просто вставляет текст, а дальше действуют обычные приоритеты. Лечится скобками – всеми сразу:

#define SQR(x) ((x) * (x))

Проблема вторая: двойное вычисление

Скобки не спасают от второй беды:

int i = 5;
int a = SQR(i++);        // ((i++) * (i++)) – i увеличится дважды, результат неопределён

Аргумент подставляется столько раз, сколько раз упомянут в теле. Побочный эффект выполняется столько же.

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

#define MAX(a, b) ((a) > (b) ? (a) : (b))

MAX("abc", "abd");       // сравнит указатели, а не строки – и молча

Ошибки внутри макроса компилятор показывает в месте вызова, в развёрнутом виде: сообщение описывает код, которого вы не писали. Отладчик по макросу тоже не пройдёт – для него это одна строка.

Чем заменить

Современный C++ обходится без макросов почти везде:

constexpr int kMaxUsers = 100;                       // вместо #define MAX_USERS

constexpr int Sqr(int x) { return x * x; }           // вместо SQR, с проверкой типов

template <typename T>
const T& Max(const T& a, const T& b) {               // вместо MAX, для любого типа
    return a > b ? a : b;
}

Всё это – настоящие сущности языка: у них есть типы, области видимости, их видит отладчик, а constexpr при этом вычисляется на этапе компиляции ровно как макрос.

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

Полностью макросы не исчезли: без них не сделать include guard и не написать #ifdef для сборки под разные платформы. Но там, где справляется функция или константа, макрос – это лишний риск, ничего не дающий взамен.