dalerank

dalerank 

I love coding, games and old cars

6subscribers

4posts

Нескучное программирование (0х2). Обобщения условий

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

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

Другая, ещё более фундаментальная проблема связана с инициализацией результата. И если в простейшей версии алгоритма мы начинаем с единицы, что для целых и вещественных чисел выглядит естественно, то стоит выйти за пределы скалярной арифметики, и вопрос перестаёт быть тривиальным. Для матриц «единицей» является единичная матрица? А для комплексных чисел? А для пользовательских типов? Сам алгоритм не должен и не может знать, какое именно значение играет роль нейтрального элемента для умножения.
Можно попытаться решить эту проблему, введя некую глобальную функцию вроде identity(T), которая возвращает нейтральный элемент для типа Tи на первый взгляд это выглядит элегантно и даже будет работать (какое-то время), но на деле такое решение слишком жёсткое. Оно навязывает глобальное соглашение, которого может не существовать, и требует от каждого типа поддерживать нестандартный интерфейс, что сразу снижает универсальность алгоритма и делает его труднее интегрируемым в существующий код.
Гораздо более естественный и «стандартный stl-ный» подход - не пытаться угадывать нейтральный элемент внутри алгоритма, а передавать начальное значение аккумулятора извне. В этом случае алгоритм получает уже корректно инициализированное состояние и просто выполняет свою работу, не делая предположений о типе данных и не реализуя логики, не относящейся напрямую к вычислению степени. Теперь вы понимаете почему stl-алгоритмы часто требуют начальный элемент?
Такой стиль полностью соответствует философии стандартной библиотеки C++. Алгоритмы в STL это своего рода «чистые комнаты», которые не проверяют входные данные на корректность, не занимаются созданием объектов и не принимают архитектурных решений за программиста. Их задача - корректно и эффективно выполнить заданную последовательность операций, а вот ответственность за подготовку аргументов, выбор начальных значений и соблюдение предусловий лежит на нас с вами и внешней логике, которая вызывает алгоритм.
Только так мы получаем действительно обобщённый алгоритм, который работает с любыми типами, и для которого определены необходимые операции, но при этом оставляем его простым, прозрачным и легко проверяемым. Именно так обобщение и перегрузки начинают работать не как трюк языка с одноименными функциями, а как инструмент проектирования.

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

Но на практике мы почти никогда не имеем дело с одной-единственной «универсальной» функцией, и вместо этого мы работаем с множеством функций объединенных одним свойством (не обязательно именем). Это не одна абстрактная power/pow, а целый набор перегруженных функций, конструкторов, операторов и методов, которые существуют под одним именем и вместе образуют единый, гибкий интерфейс. Программист видит одно имя и одну концепцию, а компилятор видит десятки разных реализаций, о которых вы можете не подозревать, и подбирает наиболее подходящую под конкретный тип.
Именно такая комбинация перегрузок позволяет писать код, который одновременно удобен, выразителен и при этом не теряет в производительности.
Тут надо подчеркнуть, что зоопарк перегрузок не конкурирует с обобщённым программированием, а дополняет его. Шаблонный алгоритм задаёт общую форму решения, а перегруженные операции и вспомогательные функции обеспечивают оптимальное поведение для конкретных случаев, т.е. мы идем не от типов данных к алгоритму, а от возможностей алгоритма к разрешенным типам данных и в результате получаем обобщенный интерфейс, который кажется простым, но внутри учитывает различия между типами.
Чтобы увидеть это на примере из самой стандартной библиотеки, не надо лезть внутрь реализации pow, можно взять простой оператор сравнения для строк. На первый взгляд задача выглядит элементарной, у нас есть std::string, есть оператор ==, и можно было бы написать его в самом прямолинейном виде: принять две строки по ссылке и сравнить их через compare и такой код будет корректным и понятным для большинства программ и разработчиков, но содержит проблему "единственного окна", или "недостаточного набора перегрузок" (insufficient overload set). Хотя это скорее описательное выражение, чем устоявшийся термин - формулировка "единственного окна" интуитивно понятна в русском комьюнити и точно описывает ситуацию, но в C++ сообществе она обсуждается как часть более широкой темы проектирования overload sets (набора перегрузок).
Проблемы начинаются, как только мы выходим за рамки идеального случая, потому что в реальном коде строки сравнивают со всем чем угодно, и очень редко сравнивают конкретно со строками. Очень часто одним из операндов будет строковый литерал, причем как слева так и справа, вроде "hello", или указатель const char*, полученный из C-интерфейса и если у нас будет только одна версия оператора, принимающая два std::string, компилятор будет вынужден выполнить неявное преобразование. Литерал превратится во временный объект строки, для которого потребуется выделить память, скопировать данные, а затем почти сразу же всё это уничтожить.
С точки зрения семантики программа в всех случаях остаётся корректной, но с точки зрения эффективности мы получаем лишнюю работу, но самое неприятное, что эта неэффективность скрыта от разработчика "удобным" вызовом. Вызов и правда выглядит безобидно, даже если под капотом происходит динамическое выделение памяти. И вот здесь проявляется сила множества перегрузок, когда вместо одного универсального оператора стандартная библиотека предоставляет несколько вариантов: сравнение строки со строкой, строки с C-строкой, C-строки со строкой. Все они логически означают одно и то же - проверку на равенство, но реализованы так, чтобы в каждом конкретном случае избежать ненужных преобразований и временных объектов.
Для разработчика интерфейс остаётся тем же самым: мы просто пишем a == "hello" и думаем о деталях реализации только в сложных случаях, или когда получаем ошибку компиляции, или видим проблемы с перфом. Для компилятора множество перегрузок превращается в набор чётко сформулированных правил, из которых он выбирает самое подходящее. Это и есть хорошо спроектированный интерфейс, который представляет STL: единое имя, единый смысл, но разные реализации, каждая из которых оптимальна для своего набора типов.
Из этого простого примера, который вы скорее всего используете каждый день, следует важное правило хорошего дизайна - перегрузки не должны менять смысл операции, а лишь уточнять её для разных форм входных данных. Если разные перегрузки делают «разные вещи», интерфейс становится запутанным и коварным, а условия применения скользкими. Именно так стандартная библиотека достигает своего баланса универсальности и удобства, без необходимости писать специализированный код вручную.
Subscription levels7

Hello World of the Nile

$1.81 per month
Первый шаг на пески Нила++. Здесь ещё можно писать printf, верить в простые решения и не знать, что такое ABI. Тир для тех, кто только начинает путь — наблюдает, читает, и аккуратно проверяет, что код хотя бы компилируется.

constexpr Писец

$3.7 per month
Пишет код так, чтобы он выполнялся ещё до запуска программы. Любит чистые структуры, ясные намерения и понимает, зачем вообще нужен constexpr. Его таблички из камня уже содержат ответы, но компилятор всё еще нужен.

Хранитель Undefined Behavior

$7.3 per month
Знает, где живёт UB, как оно выглядит и почему сегодня оно «почему-то работает». Наступал на все грабли и теперь с хитрецой наблюдате за повторением этих ошибок. Иногда исправляет баг, просто переставив строчки местами.

Верховный Жрец Оптимизаций

$14.5 per month
Говорит на языке кэшей, пайплайнов и профилировщиков. Сначала измеряет, потом ещё раз измеряет, и только потом оптимизирует. Верит в O4, LTO и священный ритуал “сначала Tracy, потом PIX”.
Subscription Spots Are Limited

Фараон Метапрограммирования

$29 per month
Повелевает шаблонами, концептами и типовой магией, из-за код компилируется долго, но выглядит красиво. Не боится requires, умеет читать сообщения об ошибках и иногда пишет такой C++, что даже компилятор задумывается.
Subscription Spots Are Limited

Compile-times Seth

$58 per month
Божество сборки и разрушения шаблонов. Повелитель минут ожидания и тысяч инстанциаций. Если он доволен, то проект собирается быстро, но может заставить компилятор страдать. Говорят, однажды он ускорит билд, просто удалив один #include.
Subscription Spots Are Limited

Akhenaten Ruler

$116 per month
Уровень для разработчиков и энтузиастов, которым мало просто читать и они хотят влиять на то, какие системы будут разбираться и исследоваться дальше.
Go up