Года четыре назад, на стыке двух проектов, когда старый уже просто сапортили, а новый только находился в стадии препродакшена и питчингов разной степени завершенности (планирование и попыток продать концепт и идеи незаинтересованным инвесторам) у моей тогдашней команды удивительным образом появилось свободное время и где-то между обучением новичков премудростям кастомного движка, попытками переключаться на 20-ый стандарт и ретроспективой бэклога, солнечным сентябрьским утром родилась идея сделать студийные обсуждения в стиле подкаста PVC по теории С++, чтобы понять, какие возможности реализованы в движке, какие компетенции есть у пополнения и вообще как-то освежить теорию. Так родился мини-курс внутристудийных лекций от разных людей с разным, но реальным опытом применения, позже осевший в местной вики в виде набора статей, бест практис или вообще заметок с упором на игродевовскую тематику. Чтобы все это добро не пропадало, ибо человекочасов туда было вбухано порядком, я решил эти заметки облагородить и выложить в читаемом виде (видео к сожалению не будет, ибо НДА и всяческие спойлеры проектов и местной кухни разработки, да и никто не будет эти десятки часов болтовни слушать), но сами принципы языка и его особенностей вещь копирайту неподвластная, поэтому в таком виде вроде можно. Если подобный формат "зайдет" аудитории Хабра, можно будет продолжить статьи в виде небольшого цикла, как это получилось с серией Game++. К сожалению, начнем не с обобщенного программирования, а со второго подкаста про перегрузки, потому что первые записи оказались испорчены и на их восстановление потребуется время. Итак перегрузка в С++, не так как её учат в универе и дают в книжках...


  • Нескучное программирование: Обобщения (WIP)

  • Нескучное программирование: Перегрузки <= Вы тут

  • Нескучное программирование: Концепты и ограничения

  • Нескучное программирование: Иерархия концептов

  • . . .

Поговорим о перегрузках и обобщённом программировании, но начнём не с синтаксиса и не с шаблонов, как оно подается в большинстве учебников и технической литературе, а с идеи. В C++ перегрузка функций и методов часто воспринимается как удобство или способ «красиво назвать разные вещи одинаково», но на самом деле это гораздо более мощный инструмент, умелое использование которого позволяет строить универсальные интерфейсы. Интерфейсы, которые можно расширять на новые типы данных так, чтобы при этом они оставались понятными и безопасными.

Чтобы это понять, нужно вспомнить, что вообще лежит в основе обобщённого программирования и его частного применения в виде перегрузок. Основатели STL, сформулировали ключевую мысль стандартной библиотеки предельно просто: сначала нужно правильно составить (придумать) алгоритм, и только потом понять, для каких типов он работает. Эта фраза часто звучит на конференциях и в книжках умных людей, но на практике её постоянно нарушают. Нарушают не потому что сложно сделать, а потому что очень легко начать с типов, классов, иерархий и других базовых вещей, понятных программисту, а уже потом пытаться «натянуть» на них алгоритм. Само обобщённое программирование предлагает противоположный путь, и если вы посмотрите алгоритмы стандартной библиотеки, то они максимально обобщены, и в большинстве случаев будут работать что для простых интеджеров, что для сложного класса с перегруженными операторами.

Начиная с задачи алгоритма, надо всегда отвлекаться от конкретных типов данных и спрашивать себя не «что это за объект», а «какие операции над ним нужны». Нужно ли уметь умножать значения, сравнивать их, копировать, создавать нейтральный элемент? Ответы на эти вопросы естественным образом будут выявлять требования к типам, которые не будут придумываться из воздуха и для ускорения (физ. величины) некоторой сущности у нас вдруг не появятся флоаты (да он подходит по синтаксису, но флоат там и рядом не стоял по семантике), а они становятся следствием алгоритма.

Классический учебниковый пример про перегрузку

Что пишут в большинстве учебников:

// "Перегрузка функций позволяет использовать одно имя 
//  для функций с разными типами параметров"

// Функция для сложения двух целых чисел
int add(int a, int b) {
    return a + b;
}

// Функция для сложения двух чисел с плавающей точкой
double add(double a, double b) {
    return a + b;
}

// Функция для сложения трёх целых чисел
int add(int a, int b, int c) {
    return a + b + c;
}

// Функция для конкатенации строк
std::string add(const std::string& a, const std::string& b) {
    return a + b;
}

"Компилятор выберет нужную функцию на основе типов и количества аргументов. Это называется разрешением перегрузки (overload resolution)."

// Павловская 
// Площадь круга
double area(double radius) {
    return 3.14159 * radius * radius;
}

// Площадь прямоугольника
double area(double width, double height) {
    return width * height;
}

// Площадь треугольника
double area(double base, double height, bool is_triangle) {
    return 0.5 * base * height;
}

int main() {
    std::cout << area(5.0) << "\n";           // круг
    std::cout << area(4.0, 6.0) << "\n";      // прямоугольник
    std::cout << area(3.0, 4.0, true) << "\n"; // треугольник
}
// Страуструп
// Комплексные числа
class Complex {
    double re, im;
public:
    // Конструктор из двух чисел
    Complex(double r, double i) : re(r), im(i) {}
    
    // Конструктор из одного числа (мнимая часть = 0)
    Complex(double r) : re(r), im(0) {}
    
    // Конструктор по умолчанию
    Complex() : re(0), im(0) {}
    
    void print() const {
        std::cout << re << " + " << im << "i\n";
    }
};

Вот эти примеры выше - это синтаксический сахар, и думая в рамках типов: «у нас есть int, double, string, давайте напишем функцию для каждого», упускается общее решение. Придуманный набор перегрузок становится способом называть похожие вещи одинаково, чтобы программисту не приходилось запоминать add_int(), add_double(), add_string(). Но это поверхностное понимание, которое не выводит программиста за рамки процедурного мышления - это не обобщённое программирование, которое видите в std, когда лезете в алгоритмы. Это просто синтаксический сахар, маскирующий набор отдельных функций под единым именем.

Обобщение алгоритма

В "Notes on Programming" Степанов пишет: "Structured Programming School: Dijkstra, Wirth, Hoare, Dahl. By 1975 I became a fanatical disciple. I read every book and paper authored by the giants. I was, however, saddened by the fact that I could not follow their advice. I had to write my code in assembly language and had to use goto statement. I was so ashamed. And then in the beginning of 1976 I had my first revelation: the ideas of the Structured Programming had nothing to do with the language.".
https://studylib.net/doc/25271030/alexander-stepanov----notes-on-programming--2018-

Я читал и эту книгу Степанова и книги Дейкстры, и мне больше нравится другой подход, который он позаимствовал у Гриса/Дейкстры (David Gries/Edsger Dijkstra) - тоже почти учебниковый пример - возведение числа в степень, вычисление x в степени n. Самое первое решение, которое приходит в голову, выглядит тривиально: мы заводим переменную-аккумулятор и n раз умножаем её на x в цикле. Это решение легко объяснить, легко написать и легко проверить и его часто предлагают на собеседованиях. Когда-то главным вопросом было не "как это сделать" (решение пишут практически все), а можно ли быстрее?

template <typename T>
T pow_naive(T x, unsigned n) {
    T result = T{1};
  
    for (unsigned i = 0; i < n; ++i)
        result = result * x;
  
    return result;
}

Оказывается, можно, и значительно. Существует алгоритм, который называется возведением в степень через квадрат, или exponentiation by squaring. Он опирается на двоичное представление показателя степени и позволяет решить задачу за логарифмическое время. Вместо того чтобы делать n умножений, мы каждый раз делим показатель пополам и возводим основание в квадрат. Если текущий показатель нечётный, мы дополнительно домножаем результат на текущее значение основания.

template <typename T>
T pow_fast(T x, unsigned n) {
    T result = T{1};

    while (n > 0) {
        if (n & 1)
            result = result * x;

        x = x * x;
        n >>= 1;
    }

    return result;
}

Интуитивно этот алгоритм можно понять так: любое целое число можно представить в двоичном виде, а значит, любую степень можно разложить на произведение степеней двойки. Например, x в степени 13 это x в восьмой, умноженное на x в четвёртой и на x в первой. Алгоритм последовательно «пробегает» по битам показателя, начиная с младшего, и каждый раз решает, участвует ли текущее основание в конечном результате.

x^13 = x * x * x * x * x * x * x * x * x * x * x * x * x

13₁₀ = 1101₂ = 8 + 4 + 1

x^13 = x^8 * x^4 * x^1

x^1 = x                    (0 умножений)
x^2 = x^1 * x^1 = x²       (1 умножение)
x^4 = x^2 * x^2 = x⁴       (2 умножения)
x^8 = x^4 * x^4 = x⁸       (3 умножения)

Биты 13: 1     1    0    1
         ↓     ↓    ↓    ↓
        x^8   x^4   _   x^1

    R = x^8 * x^4 * _ * x^1

Важно отметить не столько сам алгоритм, сколько то, что ему на самом деле требуется от типа данных. Алгоритму не важно, является ли x целым числом, вещественным, очень большим или малым целым (int6_t/int128_t), матрицей или чем-то ещё. Ему нужно лишь, чтобы существовала операция умножения, чтобы был нейтральный элемент, и чтобы значения можно было копировать. Всё остальное - будут детали реализации.

И вот здесь мы выходим на связь с перегрузками и обобщённым программированием. Алгоритм у нас один и тот же, он описан в абстрактных терминах, а конкретная операция умножения подбирается в зависимости от типа. Для int это будет одно умножение, для double - другое, а для матрицы - третье и именно через перегрузки и обобщённые интерфейсы язык позволяет связать один алгоритм с множеством конкретных реализаций.

И получается, что перегрузки становятся не просто способом избежать разных имён функций, а превращаются в механизм, который позволяет выразить идею: «один и тот же алгоритм, применимый к разным типам, если они удовлетворяют определённым требованиям». Теперь перегрузка является неотъемлемой частью обобщённого программирования, а не его побочным "удобным" эффектом.

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

Обобщение условий

Теперь, когда сам алгоритм у нас есть, логично задать следующий вопрос: как сделать его обобщённым? Как превратить решение для целых чисел в алгоритм, который будет работать не только с unsigned, но и с другими типами данных?

Первый импульс обычно очень прямолинейный - хочется просто заменить конкретный тип на шаблонный параметр, условный T, и считать задачу решённой. Формально код действительно станет шаблонным, но почти сразу становится видно, что это лишь поверхностное обобщение, а не настоящее.

Возьмём, к примеру, проверку вида x < 0. Она имеет смысл только для знаковых числовых типов. Если T — беззнаковый тип, это выражение бессмысленно. Если T — матрица, вектор или пользовательский тип, такой операции может не существовать вовсе. Это первый сигнал о том, что алгоритм в своём текущем виде на самом деле жёстко привязан к конкретным типам данных.

Проблема нейтрального элемента

Другая, ещё более фундаментальная проблема связана с инициализацией результата. И если в простейшей версии алгоритма мы начинаем с единицы, что для целых и вещественных чисел выглядит естественно, то стоит выйти за пределы скалярной арифметики, и вопрос перестаёт быть тривиальным. Для матри�� «единицей» является единичная матрица? А для комплексных чисел? А для пользовательских типов? Сам алгоритм не должен и не может знать, какое именно значение играет роль нейтрального элемента для умножения.

template <typename T>
T power(T x, unsigned n) {
    if (x < 0)            // проблема, частное решение
        x = -x;

    T result = 1;         // проблема, единица подходит не всем

    while (n--)
        result *= x;

    return result;
}

Можно попытаться решить эту проблему, введя некую глобальную функцию вроде identity(T), которая возвращает нейтральный элемент для типа Tи на первый взгляд это выглядит элегантно и даже будет работать (какое-то время), но на деле такое решение слишком жёсткое. Оно навязывает глобальное соглашение, которого может не существовать, и требует от каждого типа поддерживать нестандартный интерфейс, что сразу снижает универсальность алгоритма и делает его труднее интегрируемым в существующий код.

Гораздо более естественный и «стандартный stl-ный» подход - не пытаться угадывать нейтральный элемент внутри алгоритма, а передавать начальное значение аккумулятора извне. В этом случае алгоритм получает уже корректно инициализированное состояние и просто выполняет свою работу, не делая предположений о типе данных и не реализуя логики, не относящейся напрямую к вычислению степени. Теперь вы понимаете почему stl-алгоритмы часто требуют начальный элемент?

Такой стиль полностью соответствует философии стандартной библиотеки C++. Алгоритмы в STL это своего рода «чистые комнаты», которые не проверяют входные данные на корректность, не занимаются созданием объектов и не принимают архитектурных решений за программиста. Их задача - корректно и эффективно выполнить заданную последовательность операций, а вот ответственность за подготовку аргументов, выбор начальных значений и соблюдение предусловий лежит на нас с вами и внешней логике, которая вызывает алгоритм.

template <typename T>
T power(T base, unsigned n, T result) {
    while (n--)
        result *= base;

    return result;
}

Только так мы получаем действительно обобщённый алгоритм, который работает с любыми типами, и для которого определены необходимые операции, но при этом оставляем его простым, прозрачным и легко проверяемым. Именно так обобщение и перегрузки начинают работать не как трюк языка с одноименными функциями, а как инструмент проектирования.

Обобщение перегрузок

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

template <typename T, typename Op>
T power(T base, unsigned n, T result, Op op) {
    while (n--)
        result = op(result, base);

    return result;
}

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

Чтобы увидеть это на примере из самой стандартной библиотеки, не надо лезть внутрь реализации pow, можно взять простой оператор сравнения для строк. На первый взгляд задача выглядит элементарной, у нас есть std::string, есть оператор ==, и можно было бы написать его в самом прям��линейном виде: принять две строки по ссылке и сравнить их через compare и такой код будет корректным и понятным для большинства программ и разработчиков, но содержит проблему "единственного окна", или "недостаточного набора перегрузок" (insufficient overload set). Хотя это скорее описательное выражение, чем устоявшийся термин - формулировка "единственного окна" интуитивно понятна в русском комьюнити и точно описывает ситуацию, но в C++ сообществе она обсуждается как часть более широкой темы проектирования overload sets (набора перегрузок).

Проблемы начинаются, как только мы выходим за рамки идеального случая, потому что в реальном коде строки сравнивают со всем чем угодно, и очень редко сравнивают конкретно со строками. Очень часто одним из операндов будет строковый литерал, причем как слева так и справа, вроде "hello", или указатель const char*, полученный из C-интерфейса и если у нас будет только одна версия оператора, принимающая два std::string, компилятор будет вынужден выполнить неявное преобразование. Литерал превратится во временный объект строки, для которого потребуется выделить память, скопировать данные, а затем почти сразу же всё это уничтожить.

С точки зрения семантики программа в всех случаях остаётся корректной, но с точки зрения эффективности мы получаем лишнюю работу, но самое неприятное, что эта неэффективность скрыта от разработчика "удобным" вызовом. Вызов и правда выглядит безобидно, даже если под капотом происходит динамическое выделение памяти. И вот здесь проявляется сила множества перегрузок, когда вместо одного универсального оператора стандартная библиотека предоставляет несколько вариантов: сравнение строки со строкой, строки с C-строкой, C-строки со строкой. Все они логически означают одно и то же - проверку на равенство, но реализованы так, чтобы в каждом конкретном случае избежать ненужных преобразований и временных объектов.

string == string

const char* == string

string == const char*

Для разработчика интерфейс остаётся тем же самым: мы просто пишем a == "hello" и думаем о деталях реализации только в сложных случаях, или когда получаем ошибку компиляции, или видим проблемы с перфом. Для компилятора множество перегрузок превращается в набор чётко сформулированных правил, из которых он выбирает самое подходящее. Это и есть хорошо спроектированный интерфейс, который представляет STL: единое имя, единый смысл, но разные реализации, каждая из которых оптимальна для своего набора типов.

Из этого простого примера, который вы скорее всего используете каждый день, следует важное правило хорошего дизайна - перегрузки не должны менять смысл операции, а лишь уточнять её для разных форм входных данных. Если разные перегрузки делают «разные вещи», интерфейс становится запутанным и коварным, а условия применения скользкими. Именно так стандартная библиотека достигает своего баланса универсальности и удобства, без необходимости писать специализированный код вручную.

Хорошие перегрузки

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

void log(const char* msg);
void log(const std::string& msg);
void log(const std::string_view& msg);

log("hello");              // какая перегрузка?
std::string s = "world";
log(s);                    // тут очевидно
log(s.substr(0, 3));       // а тут?

Иногда над этим принято шутить, что, хороший набор перегрузок это тот, который написал ты (потому что ты понимаешь, что делал и как он работает). А плохой - который написал кто-то другой, потому что приходится разбираться, почему компилятор выбрал именно эту перегрузку, а не другую. В каждой шутке, как обычно, есть доля шутки, но на практике всё-таки существуют вполне конкретные критерии качества.

Эти критерии хорошо сформулировал Тим Суини (да-да, автор того самого Анриала, а до него еще и Titus Winters но более обще, и про Титуса я узнал уже сильно позже, чем про Суини) много лет назад, а плоды его подхода мы сейчас видим в развитии движка Unreal Engine и тем ценен он, что исходит не из абстрактной «красоты», а из реального опыта поддержки и развития одной из самых сложных систем в игровой разработке.

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

void log(std::string_view msg) {
    write_to_log(msg);
}

log("hello");
log(s);
log(s.substr(0, 3));

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

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

// проблема расползания интерфейса
void open(File& f);
void open(const char* path);
void open(const char* path, bool create_if_missing);

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

Плохие перегрузки

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

Формально с точки зрения языка синтаксически всё выглядит легально. Это действительно один overload set, компилятор без проблем выберет нужную версию в зависимости от типа аргумента, но с точки зрения семантики применения начинаются проблемы. Под одним именем скрываются две операции, которые не имеют между собой почти ничего общего и решают разные задачи, работают с разными областями применения и требуют разного контекста.

Попробуйте прочитать такой код без комментариев: вызов resolve(x) ещё можно как-то интерпретировать, если знать тип x. Но сам по себе он ничего не говорит о намерении автора, а вызов resolve("data/config.json") выглядит ещё хуже: неясно, что именно произойдёт — разбор строки, проверка файла, загрузка данных или что-то ещё. Чтобы понять поведение, приходится либо лезть в документацию, либо знать все перегрузки наизусть.

int resolve(int x)
{
    // нормализуем, подбираем ближайшее допустимое значение и т.п.
    if (x < 0)
        return 0;
    if (x > 100)
        return 100;
    return x;
}

int resolve(const std::string& path)
{
    // проверяем существование и читаем конфигурацию
    std::ifstream file(path);
    if (!file)
        return -1;

    int value;
    file >> value;
    ret
}

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

// тут у нас чертежи секретные
auto a = resolve(x);

// а тут мы рыбу заворачивали
auto b = resolve("data/config.json");

"Сломанные" наборы перегрузок опасны тем, что они маскируют проблему видимости и ответственности. И даже если код компилируется, выглядит аккуратно и проходит ревью, это не значит, что при изменении не придется смотреть каждую перегрузку по отдельности. Как только интерфейс начинает использоваться в бОльшем скопе или попадает в руки другого разработчика, он становится источником проблем.

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

Еще плохой пример

Если посмотреть на не очень старые изменения стандартной библиотеки критически, то можно найти примеры, где принципы хорошего дизайна наборов перегрузок нарушены. И один из самых показательных случаев это std::filesystem, об этом не раз было сказано в обсуждениях комитета.

std::filesystem часто приводят как пример «современного» и удобного API для работы с файловой системой, и с точки зрения принесенных возможностей он действительно богат и является большим таким шагом на пути развития языка, но если посмотреть именно на реализованный дизайн перегрузок, то это далеко не эталон, каким были алгоритмы.

Проблема начинается с центрального типа данных std::filesystem::path. Этот тип задуман как универсальное представление пути, и вокруг него построено огромное количество перегруженных функций, т.е. не алгоритм был началом, а сложившаяся практика примерения и тип данных. Поэтому почти каждая операция имеет несколько версий: с path, с const char*, с std::string, иногда с дополнительным std::error_code, иногда без него. Формально это всё должно выглядеть как удобство для пользователя, но на практике превращается в сложный и неоднозначный лабиринт решений, где неискушенный разработчик обычно выбирает первое подходящее решение, заметьте, не правильное, а подходящее. Думаю все видели это смешное видео, грустно, но именно оно описывает как работают с std::filesystem большинство разработчиков

Например, функция exists, в одном случае она принимает path и может выбросить исключение, в другом принимает path и std::error_code и не бросает исключений. Семантически это уже два разных контракта: одна версия работает через исключения, другая через коды ошибок, при этом имя функции одно и то же. И глядя на вызов exists(p), вы не видите в коде, какая именно модель обработки ошибок используется, пока не посмотрите на сигнатуру или документацию.

std::filesystem::exists("data/config.json");

// Бросает исключения
bool exists(const std::filesystem::path& p);

// Не бросает исключения, возвращает ошибку через error_code
bool exists(const std::filesystem::path& p, std::error_code& ec) noexcept;

Ещё хуже ситуация становится, когда в игру вступают неявные преобразованияstd::filesystem::path , которые неявно конструируются из строки. В результате вызов вроде на первый взгляд выглядит безобидно, но за ним стоит создание временного объекта path, аллокации, преобразование кодировки и, опять же, выбор между несколькими перегрузками. Чтобы точно понимать, какая версия будет вызвана, и какие побочные эффекты это повлечёт, нужно хорошо знать все перегрузки и правила их разрешения.

Отдельная проблема - это перегрузки, которые объединяют операции с разным смыслом под очень общими именами вроде status, symlink_status, exists, is_regular_file, is_directory выглядят как вариации одной идеи, но на деле имеют различия в поведении, особенно в присутствии символьных ссылок и ошибок. И разработчик вынужден постоянно помнить, какая именно функция следует ссылкам, какая нет, и как каждая из них реагирует на недоступный путь. Формально получается что это не одна перегрузка, а отдельное семейство API, которое уложили в линейное пространство стандартной библиотеки наравне с алгоритмами. Но если алгоритмы явно говорят, что они делают внутри (я делаю то, что ты видишь), то тут вся когнитивная нагрузка (читай документацию прежде использования) перекладывается на разработчика.

С точки зрения вышеописанных принципов здесь нарушается сразу несколько пунктов. Нас явно заставляют «решать загадку перегрузок» у себя в голове, и чтобы корректно использовать std::filesystem (чтобы не быть человеком с видео выше) нужно помнить не только назначение функций, но и различия между их версиями, связанными с ошибками, исключениями и неявными преобразованиями. Назначение функций не получилось выразить одним коротким именем, потому что под одним именем скрываются разные модели поведения. И, наконец, перегрузки делают не совсем одно и то же, различия не ограничиваются производительностью, они затрагивают сам смысл вызова.

Это не означает, что std::filesystem «плох» или им нельзя пользоваться. Эта часть стандартной библиотеки решает большую и сложную задачу и выбранный API только отражает явные компромиссы, которые были приняты при стандартизации. С точки зрения хорошего дизайна местный наборов перегрузок это скорее предупреждающий пример, чем образец для подражания. Код становится менее самодокументируемым, а понимание происходящего требует знания деталей библиотеки и именно поэтому, проектируя собственные интерфейсы, полезно помнить об этом примере, когда даже стандартная библиотека не застрахована от спорных решений, но тут конечно есть веские причины почему так сделали, и почему пошли на реализацию именно такого интерфейса.

Как делать лучше

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

Начнём с самого простого и, пожалуй, самого распространённого случая: перегрузок для родственных типов. Тут можно вернуться к работе со строками в плюсах, если вы посмотрите на свой код, то увидите, что представление строк сводится к двум типам (std::string и const char*). Это выглядит естественно для тех, кто использует язык, потому что оба типа представляют строку, просто в разной форме, и это историческое наследие, ставшее правилом.

Хорошей "исторической" практикой считается сделать одну из этих перегрузок базовой, а вторую тонкой обёрткой. Одна принимает (назовем её тонкой оберткой) std::string, вторая (базовая) вызывает вариант с const char*. В результате поведение всегда остаётся единым, вся логика сосредоточена в одном месте, и получается избежать создания временных объектов и лишних выделений памяти, не усложняя удобный интерфейс и реализацию.

Другой распространённый и вполне оправданный приём добавлять перегрузки по числу аргументов, представим себе функцию сoncat, которая занимается сборкой строки из нескольких фрагментов. С точки зрения пользователя удобно иметь возможность передать один фрагмент, два или три, не задумываясь о контейнерах, массиве аргументов или форматировании. Вызовы вроде concat("Hello"), concat("Hello", world) или concat("Hello", world, '!') выглядят естественно и легко читаются.

auto s1 = concat("Hello");
auto s2 = concat("Hello, ", world);
auto s3 = concat("Hello, ", world, "!");

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

Ещё один хорошо известный пример std::vector::push_back, где перегрузка используется уже не ради удобства, а ради производительности. Существует версия, принимающая const T&, предназначенная для копирования уже существующего объекта, и версия, принимающая T&&, которая позволяет эффективно перемещать временные объекты или результаты std::move. С точки зрения программиста обе функции делают одно и то же, а именно добавляют элемент в конец вектора, но с точки зрения реализации они принципиально различаются по стоимости операций.

T a;
vec.push_back(a);            // копирование
vec.push_back(T{});          // перемещение
vec.push_back(std::move(a)); // явное перемещение

Именно в этом и заключается сила такого набора перегрузок, когда семантика остаётся единой, поведение предсказуемым, а различия проявляются только в эффективности и нам не надо думать о том, какая перегрузка существует, позволяя компилятору выбрать оптимальную.

Заключение

Если обобщить эти примеры, становится понятен основной принцип при работе с перегрузками - они должны решать одну и ту же задачу, но для разных форм входных данных или разных ситуаций. Они не должны менять смысл операции и тем более не должны вводить разработчика в заблуждение. В C++ перегрузка мощный инструмент для создания удобных и производительных интерфейсов, но только при условии, что сохраняется единство семантики и здравый смысл. Как вы наверное заметили, большое количество перегрузок делает интерфейс гибким, но одновременно увеличивает риск путаницы и неявных ошибок. Чтобы набор перегрузок оставался предсказуемым и безопасным, важно явно указывать, для каких типов та или иная перегрузка допустима.

Исторически в C++ для этого использовались техники вроде SFINAE и enable_if, которые работали, но код быстро становился тяжёлым для чтения и сопровождения. С появлением концептов и requires ситуация очень сильно улучшилась и ограничения типов стали частью интерфейса, читаемой и выразительной, а управление перегрузками явным и контролируемым. Именно это позволяет сегодня строить обобщённые API, которые остаются мощными, но при этом понятными человеку, а не только компилятору. И об этом будет следующая статья...