Одна из тем, которые я всегда поднимаю на собеседовании, — value-based классы. Или value-классы. Ну хотя бы неизменяемые объекты? — зачастую приходится перебрать несколько вариантов, прежде чем собеседник поймет, чего я от него хочу. “А, DTO’шки”. Досадно, что такая важная концепция, как “значение”, имеет такую малую распространенность в мейнстримном программировании. И вот сидел я как-то вечером и тихо грустил о том, что люди не жалуют функциональное программирование. А потом вдруг подумал — а чего грустить, попробую побороться с этим хоть как могу. И пошел писать эту статью.
Анонимность
Представьте, что вам пришлось бы программировать на таком странном диалекте C++, в котором нельзя использовать выражения. Только инструкции (statements). Да-да, вы сейчас скажете, что это невозможно и что вообще ничего не получится сделать без выражений, даже функцию вызвать. Хорошо, я переформулирую: давайте не будем использовать составные выражения. Например, вот так будет выглядеть функция возведения в квадрат:
int square(int num) { num *= num; return num; }
Давайте пойдем дальше и запретим еще и возвращаемые значения. Тогда наша функция и ее использование будет выглядеть примерно так:
void square(int num, int& out_num) { out_num = num; out_num *= num; } int main() { int x = 42; square(x, x); printf("x=%d", x); return 0; }
Странный подход к программированию? Ну, он точно может показаться непривычным. Но подобный язык будет Тьюринг-полным, и мы сможем на нем сделать все, что угодно. Более того, ведь процессор примерно подобным образом и работает. У процессора нет выражений, у него есть только команды: mov, pop, lea и прочее. Да, если говорить про x86, то там есть ого-го какие заковыристые команды, а в ассемблерах даже есть синтаксис для указания регистра-со-смещением и других составных конструкций. Но в конечном итоге это все равно команды, и их набор ограничен.
И ведь когда-то программы писали именно так: командами на ассемблере. Хоть я и не застал этих времен, я уверен, что это было по-своему увлекательно. Если не верите, попробуйте поиграть в какие-нибудь околопрограммерские игры от Zachtronics. Эти игры по духу близки к пазлам, и, мне кажется, программирование на ассемблере тоже было близко по духу к пазлам. И это не плохо!
Но вернемся к истории. Программисты быстро смекнули: раз мы такие крутые автоматизаторы, то почему бы нам не автоматизировать свою собственную работу? И придумали языки программирования высокого уровня. И одной из ключевых фич в этих языках стали выражения. Теперь наконец-то можно было отдать жонглирование регистрами на откуп компилятору, а самому писать простое и понятное x = y + 2.1
И, наверное, никто не будет спорить с тем, что выражения — это хорошо и удобно? Впрочем, тут сразу стоит вспомнить про двадцатистрочные LINQ’и и Stream’ы. Так что да, во всем хорошо знать меру. Но чем именно хороши выражения, в чем их суть? Чем они принципиально отличаются от нашей square, которая возвращала значение через выходной параметр?
Давайте представим, что мы хотим воспользоваться определенной ранее функцией square для вычисления x^2 + x*2. Как это сделать в версии с выражением и в версии без выражений?
void expressions() { int x = 42; int result = square_expressions(x) + x * 2; } void no_expressions() { int x = 42; int tmp = 0; square_no_expressions(x, tmp); int result = x; result *= 2; result += tmp; }
Обратите внимание на появление переменной tmp. Я специально привел в пример такое выражение, которое невозможно вычислить без введения дополнительной переменной.2
В переменную tmp мы складываем результат вычисления x^2. Можно было бы декомпозировать вычисление по-другому и во временную переменную сложить x*2, а квадрат — в результирующую, не суть. Очевидно, вычисление x^2 есть и в версии с выражением. И компилятор точно так же сложит ее результат в “переменную” (на самом деле просто выполнит все вычисления в регистрах). С точки зрения программиста, выражение определенно имеет результат, который можно использовать. Но сослаться на этот результат можно не иначе, как через само выражение. Значение есть, а имени нет. Получается, что выражения — это анонимные значения.3
Чем хороши анонимные значения? Это инструмент выразительности. При использовании выражений для выполнения такого же объема работы нужно меньше кода. В этом коде будет меньше элементов, которые нужно читать, понимать и сопровождать. Это обоюдоострый меч, конечно, и каждый, кто распутывал огромные LINQ-выражения или вложенные fold’ы с flatMap’ами, согласится, что иногда промежуточным результатам все же лучше давать имена. И я с этим полностью согласен. Но выбор, давать имя конкретному выражению или нет, я предпочитаю оставлять за программистом.
Почему я говорю такие банальности? Да именно потому, что в наше время это стало банальностью: в 2022 году вряд ли кто-то будет спорить с тем, что наличие выражений в языке — это благо. Я думаю, сейчас никто не будет спорить даже с тем, что наличие лямбд или анонимных функций — это тоже благо. Эти фичи так давно с нами, что уже даже самые упертые ретрограды нашли им применение и согласились с тем, что иногда анонимность — это неплохо.
Присвоение
Когда у меня только зародилась идея этой статьи, я думал, что буду говорить про неизменяемые классы, equals() и ссылочную прозрачность (обо всем этом тоже будет, но чуть позже). Но в какой-то момент я вдруг вспомнил про C++ и задумался о том, насколько все эти идеи применимы в нем. Решил проверить — и не зря! Оказалось, что многие привычные, чуть ли не догматичные вещи из C#/Java/Python, в C++… не совсем нужны. Поэтому в статье немало будет сказано про C++. Но не пугайтесь, это не статья про C++ — этот язык здесь нужен лишь для иллюстрации некоторых концепций. Основные идеи применимы к программированию в общем.
Одной из отличительных особенностей C++ по сравнению с упрощенными4 языками типа Java или Python является то, что в C++ можно переопределить семантику присвоения. Это один из немногих языков, которые такое позволяют. Но что я имею в виду под семантикой присвоения? Возьмем, для примера, Python:
x = [] y = x x.append(2) print(y) # prints [2]
Выражение y = x означает “положи в y ссылку на то, на что ссылается x” или, проще говоря, скопируй ссылку x в y. После этого x и y ссылаются на один и тот же объект. В питоне вообще все есть ссылка. Любая переменная содержит ссылку. Любое значение — это ссылка на объект, но не сам объект. Если вы программируете только на питоне и никогда не сталкивались с другими языками, то это утверждение может даже показаться странным: а что, можно по-другому? Можно, смотрим Java:
var x = 2; var y = x; y += 2; System.out.println(x); // prints 2 System.out.println(y); // prints 4
В этом примере в переменную y копируется уже не ссылка на 2, а само значение 2. А все потому, что в джаве существуют примитивные типы, которые хранятся прямо по месту. Читатель, знающий питон мог бы возразить, что пример с y += 2 несколько подозрительный: если написать то же самое на питоне, результат будет абсолютно таким же: x и y будут иметь разные значения. При том, что никаких “примитивных типов” в питоне нет. Все верно, я специально привел именно такой пример. Я вам скажу больше: если переписать Java-пример с использованием объектов вместо примитивов, то результат все еще будет такой же!
Integer x = 2; Integer y = x; y += 2; System.out.println(x); // prints 2 System.out.println(y); // prints 4
Почему так происходит и в какой момент проявляется разница между ссылками и значениями, я пока оставлю вам на подумать.
Давайте подытожим то, что мы увидели в Python и Java. В питоне у нас есть лишь одна семантика присвоения, и это элегантно. В джаве у нас есть 2 сематники; нужная выбирается зависимо от категории типа, причем одна из этих категорий — фиксирована (пока что). В .NET у нас почти как в джаве, только категория примитивов не фиксирована: можно добавлять свои типы. А еще в C# появились ссылки — меня пугает, с какой скоростью этот язык обрастает сложностью. Но ссылки не меняют картину каким-то драматическим образом: у нас по-прежнему есть 2 семантики присвоения, просто выбор семантики делается уже не только по типу переменной, но еще и по тому, есть ли ref справа от =.
C++ идет дальше и позволяет программисту самому определять семантику присвоения. В этом языке можно перегрузить оператор присвоения и тем самым реализовать такое поведение, какое вы хотите. Хоть для каждого типа разное. Хотите объект, который нельзя скопировать? Пожалуйста. Хотите совместное владение и подсчет ссылок? Пожалуйста. Хотите copy-on-write? Да не вопрос: пишите код. Чтобы не быть голословным, вот вам пример, который использует исключительно классы из стандартной библиотеки:
std::vector<int> get_ints() { return { 1, 2, 3 }; } std::unique_ptr<int> get_int() { return std::make_unique<int>(42); } int main() { std::vector<int> x = get_ints(); // 1 std::vector<int> y = x; // 2 std::unique_ptr<int> a = get_int(); // 3 //std::unique_ptr<int> b1 = a; // 4 std::unique_ptr<int> b2 = std::move(a); // 5 return 0; }
Я тут пометил буквально каждую строку, поскольку каждая из них способна удивить человека, не знакомого с C++. Как думаете, в каких из них происходит копирование значения, а в каких — копирование ссылки? 5
Итак, в строке 1 у нас не происходит копирования. Массив, созданный внутри get_ints, возвращается как есть. После выхода из функции переменная x ссылается на тот самый массив, который был создан внутри get_ints. Если бы у кого-то еще осталась ссылка на этот массив и мы поменяли бы в нем значение, то наблюдатель заметил бы изменение.
Занятно то, что в строке 2, где используется, казалось бы, такой же синтаксис, происходит копирование. После этого x и y ссылаются на два разных массива и изменение одного из них не повлияет на другой.
В строке 3 у нас снова не происходит копирования. Пожалуй, ожидаемо: с вектором ведь было то же самое. Переменная a ссылается ровно на тот же объект, который был создан внутри get_int. А вот строка 4, если ее раскоментировать, вообще не скомпилируется, поскольку std::unique_ptr запрещает копирование. Заметьте: не язык запрещает копирование, а именно класс. Да, это стандартный библиотечный класс, но вы можете написать аналогичный, он не использует никаких инструментов, недоступных обычному программисту.
Как вы, наверное, догадались, строка 5 все-таки компилируется. Но как же запрет копирования?! Ну, тут мы явно говорим компилятору “спокойно, парень, я знаю, что делаю!” А делаю я тут реально серьезные вещи: я не копирую значение из a в b2, а “заимствую”, забираю его. После этой строки в b2 будет то значение, которое раньше было в a. Но что будет в a? Неизвестно. Просто не используй эту переменную больше.6
После того, как мы рассмотрели семантику присвоения в разных языках и чуть-чуть, буквально краем глаза взглянули на то, какие возможности (и сложности) таит в себе C++, давайте возвращаться в мир простоты и элегантности. Некоторое время назад я приводил в пример две программы на Java: в одной использовались примитивные типы и копирование значений, а в другой — ссылочные типы и копирование ссылок. Но результат выполнения почему-то получался одинаковый. Для освежения памяти, вот ссылочная версия программы:
Integer x = 2; Integer y = x; y += 2; System.out.println(x); // prints 2 System.out.println(y); // prints 4
Я поднимал вопрос: почему эта программа работает так же, как и версия, использующая примитивные типы? Все просто: потому что так определена семантика Integer. Безобидное, на первый взгляд, y += 2 под капотом превращается во что-то вроде y = Integer.valueOf(y.intValue() + 2)
. В отличие от C++, в Java вся эта магия заложена на уровне языка: вы не можете сделать свой аналог Integer, для которого можно будет написать y += 2.
Python в этом вопросе гибче. В нем выражение y += 2 превращается в y = y.__iadd__(2)
7. А __iadd__ — это метод, который вы можете переопределить для своего класса. Так что в питоне, в отличие от джавы, можно сделать свой аналог встроенного типа int. Но обратите внимание на то, что раскрытое выражение все еще содержит оператор присвоения, смысл которого фиксирован и не подвластен программисту.
Интересная особенность питоновского __iadd__ заключается в том, что с его помощью можно моделировать как изменяемые, так и неизменяемые объекты. Сравните, например, как ведет себя стандартный список и не менее стандартная строка:
x = [2] y = x y += [3] print(x) # prints [2, 3] x = "a" y = x y += "b" print(x) # prints "a"
Почему в примере со списками исходный объект изменился, а в примере со строками — нет? Да просто потому, что так определено поведение этих типов. Списки в Python изменяемые, а оператор += для них добавляет элемент в существующий список и возвращает его. Строки в Python неизменяемые, а оператор += для них возвращает новую строку, которая является конкатенацией исходной строки и добавляемой. Это выбор разработчиков языка. Они решили, что это разумное поведение для данных классов. А вот в C++, например, строки изменяемые. Вы, возможно, слышали о том, какие плохие строки в C++ (особенно если вы больше не пишете на этом языке). Потому что они… изменяемые! Но значит ли это, что списки в Python тоже очень плохие? И значит ли это, что int (именно int, не Integer) в Java — тоже плохой?
Неизменяемость
На самом деле корни недовольства строками растут из времен, когда в C++ не было rvalue semantics, а писать быстрые программы было даже актуальнее, чем сейчас. И потому строки приходилось передавать по ссылке. Мутабельное и по ссылке — звучит как надежный способ выстрелить себе в ногу.8 Заметьте, в Java вы не можете передать мутабельный int по ссылке, хотите ссылку — создавайте объект. А вот питоновским спискам нет оправдания, увы — они мутабельные и всегда по ссылке.
Так что получается, если не передавать строки по ссылке, то можно и в C++ жить, как во всем цивилизованном мире? В целом да, но есть один момент…
Посмотрите, например, на определение функции string::replace из стандартной библиотеки C++. Если просканировать левую часть всех доступных перегрузок, то можно заметить, что все они возвращают basic_string&, что означает “ссылка на изменяемый basic_string”. А это значит, что операция replace изменяет исходную строку.9 Это позволяет достичь хваленной производительности C++: можно написать максимально эффективный код, безо всяких там лишних выделений памяти и копирований. Но за это приходится платить большей сложностью:
std::string str = "hello"; str.replace(2, 2, "r"); // 1 str.replace(2, 2, "y"); // 2 std::cout << str << std::endl;
Вывод этой программы зависит от того, в каком порядке расположены строки 1 и 2. В чем тут сложность? Не надо ведь большого ума, чтобы понять, что выведет эта программа? Но большой ум понадобится, когда эти строки будут размещены в разных классах и их не будет связывать непосредственная цепочка вызовов. Когда мы имеем дело с изменяемым состоянием, понимание нашей программы может сильно усложниться, поскольку результат выполнения кода зависит от того, какой код был выполнен ранее. Вот есть два подписчика на событие, первый из них останавливает процесс, генерирующий события: будет ли вызван второй подписчик? Да и вообще, корректно ли отработает останов обработки, будучи вызванным из обработчика? Если вы когда-нибудь задавались подобными вопросами, проектируя программы, то прекрасно понимаете, о каком усложнении идет речь.
Давайте сравним это с программой, использующей неизменяемые строки:
val str = "hello" val str1 = str.replaceRange(2, 4, "r"); // 1 val str2 = str1.replaceRange(2, 4, "y"); // 2 println(str2);
Как видите, в этом примере мне пришлось ввести новые имена. Если я попытаюсь поменять местами строки 1 и 2, то программа просто не скомпилируется, потому что вторая строка ссылается на переменную, объявленную в первой строке. Это ключевой момент. Когда мы оперируем константами, мы обязаны давать новое имя при любом “изменении”, либо довольствоваться анонимным значением. Когда мы ссылаемся на эти значения, мы обязаны точно указать, какую версию мы имеем в виду. Наша переменная не зависит от того, что произошло в прошлом. Но она зависит того, что было объявлено выше. В версии с изменяемыми переменными строка str.replace(2, 2, "r")
делит программу на две части во время выполнения: то, что случилось до и то, что будет после. В версии с константами строка val str1 = str.replaceRange(2, 4, "r")
делит программу на две части лексически: то, чтобы находится выше по тексту и то, что находится ниже. Иначе говоря, программа с константами явно выражает то, что в программе с переменными выражено неявно.10
Если вы пишете на Java, то вам наверное знакомо такое понятие как value-класс. Документация Java говорит, что такие классы должны быть неизменяемыми. Но как же int? Ведь он изменяемый, но при этом все же является значением? А в C++ мы вообще можем делать свои классы, которые при использовании ведут себя как примитивы Java. Если плотно задуматься об этом, то становится понятно, что семантика значения — это не совсем атрибут класса. Это атрибут использования класса. Передали int по ссылке? Такой int не может быть значением для того, кому вы его передали. Действительно, ведь это не значение, а место, в котором лежит значение, просто по определению. Передали string по ссылке? То же самое. А вот если передать string по значению, то получатель получит именно значение, а не место, в котором это значение лежит. По сути, передача string по значению позволяет абстрагироваться от того, как именно в машине все работает. В Java мы не можем передать объект по значению. Но мы можем сделать такой объект, который никогда не сможет измениться. Как мы видели ранее в примере с Integer, обнаружить разницу между передачей неизменяемого объекта по ссылке и передачей изменяемого объекта по значению, невозможно.
Запретить изменение объекта в одном месте — в определении класса — гораздо проще, чем принимать это решение во всех местах, где этот класс используется. Но у подхода C++ есть свое преимущество: там, где надо, я могу использовать класс мутабельно и выиграть в производительности. И в этом весь C++: zero cost abstractions любой ценой.
Давайте теперь посмотрим на значения и изменяемость под еще одним углом. Каким бы языком программирования вы ни пользовались, переменная в программе так или иначе будет привязана к ячейкам памяти компьютера. Значение же — это абстрактная логическая сущность, привязанная к переменной. Например, число 2. Когда мы думаем про число 2, нас не заботит, как оно хранится памяти (если только мы не занимаемся сериализацией или еще какими низкоуровневыми вещами). Когда я в переменную кладу другое число, я, по сути, связываю эту переменную с другим логическим значением. Но само значение 2 при этом никак не изменилось: понятие “изменение” в принципе не имеет смысла в контексте целого числа (“заменим 2 на 3 во всей математике!”). Но изменилось содержимое переменной, то есть ячейки в памяти. В случае с Integer физически поменялись байты, кодирующие ссылку на объект-число, в случае с int — физически поменялись байты, кодирующие само число. Но мне, как программисту, по большому счету все равно, как именно работает связывание логического значения с физической памятью до тех пор, пока работают все инварианты, которых я ожидаю от целых чисел. Работа на уровне значений позволяет нам абстрагироваться от особенностей хранения в памяти примерно таким же образом, как использование выражений позволяет нам абстрагироваться от менеджмента регистров и стека.
Итак, мы выяснили, что значения11 неизменяемы по своей природе. Что еще мы можем сказать про них?
Эквивалентность
Давайте рассмотрим небольшой пример на Python:
x = 22 ** 22 y = 22 ** 22 print(x == y) # prints True print(id(x), id(y)) # prints two different numbers
Для не-питонистов: оператор ** возводит в степень, функция id возвращает некоторое число, однозначно определяющее идентичность объекта (читай, адрес).
Мы ожидаем, что если два раза вычислить 22 ** 22, то результаты совпадут. Так оно и есть, первый print это подтверждает. Но второй print нам говорит еще кое-что: физически для сохранения результатов этого вычисления были созданы разные объекты. Я специально сделал такое большое число. Если вместо 22 ** 22 написать просто 22, то адреса x и y скорее всего совпадут. Потому что для небольших целых чисел в питоне используется кэширование, да и в джаве тоже.
Этот пример иллюстрирует одну важную особенность значений: они могут совпадать, даже если представлены… да не важно. Этой фразы достаточно: значения могут совпадать. То есть мы можем сравнить два значения и сказать, эквивалентны они или нет. Соответственно, объект, представляющий собой значение, должен определять операцию сравнения. И эта операция должна зависеть от полного видимого состояния объекта. Это важный момент: не любой класс с переопределенным equals является значением. Да-да, я смотрю на вас, любителей сравнивать энтитяшки по айдишкам.
Но не все классы могут быть значениями. Рассмотрим, для разнообразия, пример на C#:
var sw1 = new Stopwatch(); sw1.Start(); var sw2 = new Stopwatch(); Console.WriteLine(sw1 == sw2); // prints false
В примере выше sw1 и sw2 не равны.12 Более того, они не будут равны даже если я уберу вызов sw1.Start()
— я эту строку добавил исключительно для того, чтобы подстегнуть мыслительный процесс. Если посмотреть, что из себя представляет Stopwatch, и немного подумать, то станет понятно, что для него в принципе невозможно осмысленно определить понятие эквивалентности. Можно было бы сказать, что два Stopwatch’а равны, если совпадает, сколько времени прошло с момента запуска. Но какую практическую пользу это несет? Сейчас они совпадают, а мгновением позже — уже нет. Вряд ли можно построить какую-то разумную логику на мимолетном совпадении состояний двух Stopwatch. Можно построить логику на сравнении, скажем, двух интервалов времени, которые были отмерены Stopwatch’ами. Но интервал времени — это не то же самое, что секундомер.
Мы можем сказать, что эквивалентность Stopwatch определена следующим образом: каждый объект Stopwatch равен только самому себе. Про такие объекты говорят, что они обладают идентичностью. Разделение объектов на объекты-значения и объекты, обладающие идентичностью, очень полезно при проектировании программ.13
Но, погодите, разве “каждый объект равен самому себе” — это не то, как по умолчанию работает оператор сравнения в многих современных языках? Получается, по умолчанию все объекты обладают идентичностью? Так и есть. И я не считаю это удачным “умолчанием” в 2022 году. Видимо так думаю не только я, о чем можно судить хотя бы по появлению records в Java и C#.
Идентичность — это чуть более обширная тема, чем равенство ссылок на объекты. Идентичность есть и в реальном мире: вы и я оба обладаем идентичностью. Откуда она берется? Поразмышляйте на досуге. А пока что я скажу только, что изменяемые объекты с состоянием — это не единственный способ представления идентичности. И ту идентичность, которая растет из реального мира, как раз-таки можно и зачастую полезно представлять не с помощью ячеек в памяти машины.
Значения на практике
Изначально здесь располагался раздел про преимущества значений, в котором я пытался убедить вас в том, что значения — это классно, и что их обязательно нужно использовать. Но потом я подумал и решил не тратить на это силы. Убеждения почти никогда не работают: человек либо заранее склонен согласится с чем-то, либо нет. Чтобы поменять взгляд на вещи, требуется немало времени и немало практики. И если вы все еще скептичны насчет значений, то вам будет полезней узнать, как их готовить, чем в очередной раз услышать, как же, черт возьми, они хороши.
Как вообще значения используются в программировании? Мы уже определились с тем, что значения должны как минимум поддерживать операцию сравнения. Вообще говоря, сравнение — это один из ключевых элементов программирования, ведь именно сравнение является основной для логики. Всякий раз, когда мы хотим сделать ветвление или цикл, нам нужно выразить условие. И это условие всегда базируется на каком-то сравнении: если значение больше 0, если значение истинно, если пятый бит в числе установлен и т.п.
Ну а помимо сравнения… мы можем определить любые другие операции, которые нам нужны. Я склонен определять тип значения именно операциями, которые он поддерживает, а не его структурой. Мне вообще нравится думать про программы в контексте операций: это именно то, ради чего мы вообще пишем ПО: чтобы программа что-то делала. Мы не делаем софт просто для того, чтобы нарисовать красивые диаграммки и смоделировать отношения между данными. Даже если мы, скажем, разрабатываем СУБД — мы все равно реализуем какие-то действия: вставку, удаление и т.д. В конечном итоге машина делает именно действия, и именно ради этих действий мы машины и используем.
Возвращаясь к операциям; давайте рассмотрим наши любимые строки. У строки можно узнать длину. Из строки можно получить символ по индексу. В строке можно заменить подстроку. Строку можно привести к нижнему регистру. Есть еще куча операций, которые можно выполнить над строкой. Вот пример на Python:
x = "hello" len(x) # returns 5 x.replace("ll", "xx") # returns "hexxo" x.upper() # returns "HELLO"
Обратите внимание на различие между вызовами len и replace. В len мы передаем строку как аргумент, а replace вызываем как метод объекта. Почему? Я думаю, так исторически сложилось. Но я специально привел этот пример, чтобы показать, что в контексте нашего разговора нет никакой разницы, пишете вы a.f() или f(a).14
Вы скажете, со строками все понятно. Но как выглядят операции в прикладном коде? Ну, здесь у нас уйма вариантов. Например, мы можем делать старое доброе ООП, как его делали 20 лет назад:
class Weapon { IList<WeaponModule> modules = new List<WeaponModule>(); public void AddModule(WeaponModule module) { modules.Add(module); } public bool CanShoot => !modules.Any(x => x.PreventsShooting); ... }
В примере выше CanShoot и AddModule — операции. И вот по поводу первой я ничего не имею против, но вторая мне категорически не нравится. Она вызывает кучу вопросов: а состояние Weapon’а точно консистентно после добавления модуля на лету? Даже в процессе стрельбы? И, вообще говоря, нам действительно нужна возможность досыпать модули на лету? Или будет достаточно сконструировать оружие один раз и больше не менять состав модулей?
Вы, наверное, прекрасно знаете паттерн для устранения подобных вопросов — builder. Вместо создания Weapon и наполнения его модулями, мы создадим временный билдер, наполним его модулями, а затем получим рабочий Weapon:
Weapon weapon = new WeaponBuilder() .AddModule(new StandardWeaponClip(maxAmmo: 30)) .AddModule(new ProjectileSpawner()) .Build();
Неужели такой дизайн лучше? В 95% случаев — да. Несмотря на то, что появился еще один класс. Зато вы радикально снизили сложность класса Weapon. И оно того стоит. По себе знаю, что по молодости смущался делать “тупой” код, мол, зачем мне иметь Builder на каждый класс? Не надо драматизировать, не на каждый! И даже если так, что плохого в этих безобидных Builder’ах? В них сложно ошибиться.
Возможно, ваш язык позволяет делать именованные аргументы, а также имеет удобные конструкторы коллекций. В этом случае вам скорее всего вообще не нужны builder’ы:
val weapon = Weapon( modules = listOf( StandardWeaponClip(maxAmmo = 30), ProjectileSpawner()))
Разница между изменяемым Weapon из первого примера и неизменяемым Weapon из последнего примерно такая же, как между изменяемым std::string и неизменяемыми строками в Java. Да, с помощью изменяемых объектов можно достичь большей производительности. Но почему в высокоуровневых языках мы предпочитаем иметь неизменяемые строки? Потому что они снимают с программиста обязанность думать о том, безопасно ли передавать строку по ссылке при каждом использовании.
Если мы смирились с тем, что неизменяемые строки достаточно хороши по производительности (а для мест, где недостаточно, есть StringBuilder), то почему мы не используем неизменяемые коллекции? Почему коллекцию символов, то есть строку, принято представлять в виде неизменяемого объекта, а коллекцию целых чисел — нет? На самом деле большинство языков программирования, которые появились за последние полтора десятилетия, имеют неизменяемые коллекции в своих стандартных библиотеках. Лично для меня эта тенденция к неизменяемости (и, соответственно, к функциональщине) очевидна — вся индустрия плавно движется от императивного к функциональному. Почему это происходит? Распространенное мнение: потому что многопоточность. Мол, у нас сейчас рост производительности идет за счет увеличения количества вычислительных ядер, поэтому нам нужно делать параллельные программы, а ФП хорошо для этого подходит. Это все, конечно, правда, но я не думаю, что это главная причина.
Я считаю, что весь мир давно хотел работать в функциональном стиле, просто железо не позволяло. Это заявление вам может показаться бредом, особенно если вы из тех программистов, которые считают, что ООП “понятней”, “интуитивней” и что “именно так люди мыслят”. Но люди не всегда знают, чего они на самом деле хотят. Вернее, они не всегда знают, что им на самом деле нужно. Чтобы программист пришел к осознанию того, что ему нужно ФП, ему нужно, во-первых, набить шишек на мутабельности и, во-вторых, попрактиковать функциональное программирование. А чтобы попрактиковать функциональное программирование, нужно для начала хотя бы узнать, что оно существует. И лет 20 назад сделать это было не так-то просто! Это сейчас ФП занимает видное место в информационном поле и потому кажется, что профессиональный программист просто не может не знать о нем. Но так было не всегда.
На всякий случай стоит уточнить, что функциональный стиль программирования определяется отсутствием присвоений, то есть, отсутствием изменяемого состояния. Именно это является ключевым фактором, а не какие-то там “функции как объекты первого класса” и всякие прочие замыкания. Функция, результат которой полностью определяется ее входом и которая никак не полагается на изменяемое состояние, называется чистой. Вызовы таких функций являются ссылочно-прозрачными выражениями — такие выражения могут быть заменены результатом их вычисления без изменения поведения программы. Это все теория, но в чем практическая польза этого вашего функционального программирования? О, вы могли слышать уйму ответов на этот вопрос. Уж больно много нынче хайпа вокруг ФП. Я в этой статье пытался найти объективные отличия одного стиля от другого и выделить какие-то крупицы знания, не окрашенные желанием рассказать всему миру про свой первый секс функциональный опыт. Поэтому мой ответ на вопрос “почему ФП?” звучит так: потому что функциональный стиль еще больше абстрагирует нас от того, как работает компьютер.
Не поймите меня неправильно, я не говорю, что нужно полностью исключить любые мутации из программы и сделать все функции чистыми. Но я призываю придерживаться простого правила: чистая функция обычно лучше функции с побочными эффектами. Неизменяемый объект обычно лучше, чем изменяемый. Программа с десятью присвоениями лучше, чем программа с сотней присвоений.
После того, как я употребил слова ФП и ссылочная прозрачность, я хочу вернуться к теме сравнения еще раз. Дело в том, что когда я говорил про эквивалентность, я использовал фразу, которая требует некоторого пояснения — “зависеть от полного видимого состояния”. Не знаю как у вас, но у меня “видимое состояние” плотно ассоциируется с геттерами. Тяжелое ООПшное детство, не иначе. Так вот, мне не очень нравится эта ассоциация. Видимое состояние определяется операциями, доступными для типа. В том числе геттерами, но не только ими. Да-да, геттеры — это тоже операции. Геттер — это функция из множества моего типа в множество какого-то другого типа, зачастую примитива, например, чисел (getAge) или строк (getLastName). Но мы с тем же успехом “видим” состояние, например, строк через replace или concat. Так вот, эквивалентность двух значений означает, что все операции над этими двумя значениями вернут эквивалентные результаты. Если a equals b, то (x concat a) equals (x concat b). Если это соблюдается для всех операций, мы имеем класс-значение. На практике, конечно, сравнение для класса-значения обычно сводится к сравнению всех полей. Просто полезно понимать, что исходный смысл сравнения немного в другом.
Ну и напоследок давайте рассмотрим один пример по мотивам моего рабочего проекта. Этот пример призван показать вам, что при помощи значений можно моделировать не только игрушечные примеры из статей, но и что-то более приближенное к реальности.
data class Progression( val progressionId: ProgressionId, val progressionSteps: List<ProgressionStep> ) { fun rewardForLevel(level: Int): PotentialAcquisition = ... } data class ProgressionStep( val xpToEarn: Int, val reward: PotentialAcquisition ) data class ProgressionState( val xpEarned: Int, val claimedLevels: Set<Int> ) { fun claim(levelToClaim: Int): ProgressionState = ... } data class ResolvedProgressionState( val xpEarned: Int, val reachedLevel: Int, val claimedLevels: Set<Int> ) { fun reachedButNotClaimedLevels(): Set<Int> = ... companion object { fun forProgressionAndState( progression: Progression, state: ProgressionState ): ResolvedProgressionState = ... } }
В приведенном выше примере все классы являются значениями. Для сокращения объема я опустил реализации всех методов, а также определение класса PotentialAcquisition Все эти классы-значения вполне себе можно загружать, например из JSON, их можно сохранять в БД. Да, операции чтения и записи в БД не будут чистыми, по определению. Но у нас и нет цели сделать программу 100% чистой.15
Самое главное, что на таких значениях можно строить логику. Вот, например, как можно реализовать операцию получения наград за уровень, который был достигнут игроком, но за который он еще не получил награду: 16
data class ClaimOutcome( val newState: ProgressionState, val rewards: PotentialAcquisition ) fun claimProgressionLevelsRewards( progression: Progression, progressionState: ProgressionState, levelsToClaim: Set<Int> ): ClaimOutcome { val resolvedState = ResolvedProgressionState.forProgressionAndState( progression, progressionState) val levelsToActuallyClaim = levelsToClaim intersect resolvedState.reachedButNotClaimedLevels() val newState = levelsToActuallyClaim.fold(progressionState) { state, level -> state.claim(level) } val combinedReward = levelsToActuallyClaim .map { level -> progression.rewardForLevel(level) } .fold(PotentialAcquisition.empty, PotentialAcquisition::plus) return ClaimOutcome(newState, combinedReward) }
Итоги
Статья получилась длинная, а в процессе я даже наткнулся на выводы, которых не закладывал изначально. Я не собирался углубляться в C++ и уж точно не думал, что вдруг пойму, что значения не обязательно представлять в виде неизменяемых объектов. Так что как минимум одному человеку — мне — эта статья уже в чем-то помогла. Ну а вас, надеюсь, сподвигла взглянуть на программирование с необычного ракурса.
Напоследок давайте еще раз освежим основные тезисы:
- Анонимность — полезное средство выразительности. Средство абстракции, если хотите. Оно позволяет убрать из программы нерелевантные детали. Точно ли эти детали нерелевантны? Это уже решать программисту, не вините инструмент.
- C++
— лучший язык на свете и one loveочень мощный язык, поскольку он позволяет управлять вещами, которые в большинстве языков даются как данность. Но за это приходится платить. - Неизменяемые value-классы популярны в языках вроде Java, но это не единственный способ использовать значения в программах.
- А использовать значения в программах очень даже полезно! Это позволяет работать в функциональном стиле, который делает невозможным целый ряд ошибок.
- Значение должно обладать двумя свойствами: эквивалентностью и неизменяемостью. Эквивалентность означает, что мы можем сказать, являются ли два значения одним и тем же. Неизменяемость означает, что значения существуют в математическом смысле как абстрактное понятие, они не являются особенностью работы конкретной машины.
- Полезно думать про значения в терминах операций, которые над этим значением работают, а не в терминах их структуры.
Примечания
- Тут стоит уточнить, что выражения — это не то же самое, что инфиксная запись. Например, в LISP есть выражения, но нет инфиксной записи.
- Вот вам задачка: как вычислить минимальное количество временных переменных, необходимых для расчета выражения?
- Кстати, лет пятнадцать назад стало модно в языки программирования завозить другие анонимные штуки, работающие по такому же принципу: использовать можно, а сослаться нельзя. Назовете?
- No offense, простота — это же благо в разработке ПО?
- Формально там везде значения, но нас интересует не совсем это — с тем же успехом можно сказать, что в джаве тоже всегда копируется значение, просто иногда это значение указателя.
- Эх, есть что-то в этом C++. Сколько раз я называл его нецелесообразным для решения прикладных задач, сколько раз бросал, но все равно в моем сердце все еще выделено для него место…
- Может превращаться в это. Но есть и другие варианты.
- Если вы сейчас подумали о том, что в C++ такой проблемы нет, поскольку можно передавать ссылку на const-объект, то подумайте еще о том, является ли этот объект константой для отправителя.
- А какую еще lvalue ссылку на string, кроме *this, может вернуть этот метод?
- Примечательно, что компиляторы могут преобразовывать ваши программы с изменяемыми переменными именно в такой вид просто потому, что так их проще анализировать и оптимизировать, см. SSA.
- На всякий случай уточню, что я использую слово “значение” в математическом смысле. При определении семантики языков программирования это слово обычно имеет другой смысл. Например, такой.
- Для программистов на Java: в C# оператор == подразумевает вызов Equals для объектов, так что в этом примере проблема не в том, что мы написали == вместо Equals.
- Что используется, например, в Domain-Driven Design — с набором практик, описанных в этой книге, я всем советую как минимум ознакомиться.
- Разница есть с точки зрения ООПшного полиморфизма. В вызове a.f() используется динамическая диспетчеризация по типу a, в вызове f(a) такой диспетчеризации не происходит. Но это важно только там, где вам действительно нужен полиморфизм. А еще
в некоторыхво многих языках есть ограничения на доступ к данным извне класса. - Вообще говоря, чтобы программа несла какую-то пользу, она должна изменять окружающий мир, то есть, иметь “побочные эффекты”.
- Мы правда пишем так в проде. Приходите к нам :-)
А еще Java пытается не отставать и идти в ногу с современными концепциями. Как минимум появились записи – https://openjdk.java.net/jeps/395, но вскоре и value objects будет – https://openjdk.java.net/jeps/8277163.