В предыдущей части обсуждался доступ к разным элементам коллекции одновременно.
В этой рассмотрим случай вложенных ссылок.
Возьму пример из разрабатываемой игры RDS. Есть игровой объект, который содержит какие-то данные. Допустим:
Есть поведение объекта, которое как-то обрабатывает данные объекта. Допустим:
Но кроме того, поведение может иметь и обрабатывать собственные данные. Например, объект должен пройти 5 шагов. Поведение в каждом шаге игрового цикла изменяет координату объекта (это пройденный шаг) и увеличивает собственный счётчик шагов. Добавим это всё в поведение:
Отметим, что теперь в метод update() передаётся параметр не &self, а &mut self.
Что за self?
В ООП к объекту могут быть привязаны методы. Метод это обычная функция, но отличается она тем, что "принадлежит" какому-то объекту, и может иметь к нему доступ. Ссылка на этот объект передаётся в метод автоматически в виде аргумента.
Во многих языках программирования передача этого аргумента в явном виде опускается, и он существует по умолчанию с названием this. Обращаясь к this, метод обращается к тому объекту, из которого он вызван.
В других языках, таких как Python, Rust и Perl, ссылка на объект передаётся в аргументах явно и по общему соглашению называется self.
В Rust дополнительно указывается, что ссылка является мутабельной или немутабельной. Чтобы метод мог менять данные в объекте, ссылка должна быть мутабельной – &mut self.
Это всё пока ещё вступление к описанию проблемы.
Композиция
Мы можем создать объект, создать поведение, и поведением обработать данные объекта:
Это работает. Но дальше мы хотим сделать так, чтобы у разных объектов были разные поведения. Т.е. условно говоря мы создадим типы Object1, Object2, Object3 и Behaviour1, Behaviour2, Behaviour3.
И для обработки объектов будем писать что-то типа условий: если это объект типа Object1, то вызвать метод поведения Behaviour1, и т.д.
Но так получается неудобно. Во-первых, в коде появляется простыня из условных операторов, во-вторых, эти условия оказываются жёстко прописанными. Мы таким образом не можем во время выполнения программы взять и назначить объекту 1 поведение 2, потому что проверка не сработает правильно.
В таком случае решение это связать непосредственно два объекта, и у него даже есть название – шаблон проектирования "Композиция".
Мы помещаем ссылку на поведение в наш объект:
Тут, во-первых, появилось время жизни 'a, но дело не в нём и можно пока представить, что его нет. Теперь объект содержит мутабельную ссылку на поведение и мы можем назначить ему любое поведение (опять же, это не совсем так, потому что нужна ссылка на трейт, но это не меняет сути проблемы).
Почему объект содержит именно мутабельную ссылку? Разберём эту строчку:
obj.bhv.update(&mut obj);
Мы вызываем у поведения метод update(), в который передаётся мутабельная ссылка на само поведение &mut self, потому что оно меняет собственные данные.
Аргумент &mut self явно указан в сигнатуре метода update(), но не передаётся туда явно. Это делается автоматически.
То есть вызов метода update() на самом деле (под капотом) выглядит так:
obj.bhv.update(obj.bhv, &mut obj);
Если в объекте поведение будет сохранено как &Behaviour, то мутабельную ссылку создать будет нельзя, потому что уже есть немутабельная. Если же оно сохранено как &mut Behaviour, то это и есть мутабельная ссылка, которая будет использована (то есть ещё раз создавать её не надо).
Но такой код всё равно не заработает, потому что кроме ссылки на поведение мы передаём в update() мутабельную ссылку на сам объект &mut obj – ведь поведению нужно менять его данные.
Мутабельная ссылка на объект действует целиком на весь объект. Но наш объект уже содержит мутабельную ссылку на поведение. Значит, когда мы получаем ссылку &mut obj, мы в том числе пытаемся получить &mut obj.bhv, а она уже существует и может быть только одна. Ничего не работает!
Вот такая проблема на ровном месте может просто вывести из себя, потому что в других языках всё прекрасно работает.
Что делать?
Первое, чему следует научиться в Rust, это правильная грануляция данных. Скажем, нам необязательно передавать в метод update() ссылку на весь объект. Если поведение меняет его данные, значит надо передать только ссылку на данные:
Теперь всё работает, потому что есть мутабельная ссылка на поведение (это одна часть памяти объекта) и есть мутабельная ссылка на данные (это другая часть памяти объекта). Память у этих ссылок не пересекается и поэтому владеть ими можно одновременно.
В структурах можно делать любое количество вложенных подструктур, это никак не отразится на производительности или занимаемой памяти. Только лишь удлинит обращение к полям подструктур, вроде:
pixel.rgb.r
Отказ от передачи объекта
Когда это возможно, лучше вообще отказаться от мутабельных ссылок на объект. Скажем, поведение не будет напрямую менять данные объекта, а только возвращать результат, который затем применится к данным:
Копирование
Можно вместо мутабельной ссылки на объект передавать копию данных объекта. Поведение изменит копию и вернёт её обратно:
В целом копирование это лучший выход, если размер данных незначителен.
Ослабление связей
К примеру, если есть список объектов, то можно сделать ещё один список поведений. Тогда первому объекту из списка объектов будет соответствовать первое поведение из списка поведений, и т.д.
Таким образом непосредственная связь между объектом и поведением разрывается и замещается опосредованной, через позицию в списке.
Мутабельная ссылка на поведение объекту уже не нужна. Но данный способ усложняет саму организацию кода – нужно вести два списка, нужно следить за тем, чтобы позиции в них были синхронизированы, и т.п. При аккуратном подходе это можно сделать, но всё же данный способ не очень привлекателен.
Шиворот-навыворот
Возможно, иерархически объект устроен неправильно. Что, если мы не поведение сделаем свойством объекта, а объект свойством поведения?
Теперь поведение содержит в себе сам объект – не ссылку на него, а прямо его. Иначе говоря, мы обернули существующий объект в некое поведение, и оно может им распоряжаться как собственными данными.
Так тоже работает, однако семантически может быть неверно. Обычно мы рассуждаем, что объекты имеют поведения, а не поведения имеют объекты. Хотя, рассматривая поведение как обёртку, подвести базу под это всё же можно.
Данный способ можно применять только в ограниченных случаях, когда в сложных объектах одно перезаворачивание не будет противоречить другому. Также нельзя будет поместить разные поведения в один список, так как они разные.
В общем, решение одной проблемы создаст другие, и надо просто выбирать, с чем легче справиться.
Хранение данных поведения отдельно
Если вынести данные из поведения и оставить только реализацию метода, оно станет пустым и необходимость передачи ссылки &mut self отпадёт – модифицировать поведению в самом себе уже нечего. Ссылка на данные для модификации будет храниться в объекте как bhv_data:
Ссылка &self у метода поведения стала немутабельной, что сразу облегчило жизнь. Немутабельных ссылок можно брать сколько угодно.
При этом память под данные поведения будет выделяться так же, как раньше она выделялась под весь объект поведения, это ничего не меняет в механизме хранения. Но вот само поведение теперь пустое, от него остался только метод, и значит, поведение можно теперь хранить статически одно на любое количество объектов.
Мы отделили метод от данных, и это принесло какую-то пользу.
Порядок аргументов
Теперь рассмотрим довольно забавный казус. Предположим, есть некий сервис, который делает что угодно:
Здесь для примера написан только один метод, но предположим, что их много, и что сервис содержит разные подсервисы и т.д.
И мы в какой-то метод передаём мутабельную ссылку на этот сервис. Но кроме того, передаём заранее вычисленное значение, которое можно получить только от этого же сервиса:
Т.е. смотрите, два аргумента: &mut seq и seq.next(). Второй вернёт целое число, т.е. по сути мы передаём просто тип i32, который и обозначен в сигнатуре метода. Но так работать не будет, следим за руками:
- Первый аргумент это ссылка &mut seq
- Чтобы вычислить второй аргумент, нужно вызвать seq.next(), а чтобы его вызвать, нужно получить &mut self. А нельзя – потому что &mut seq уже занят!
А теперь перепишем аргументы в другом порядке:
Так оно уже работает:
- Первый аргумент это вычисленное значение seq.next(), для чего берётся ссылка &mut self, и эта ссылка прекращает существовать после вычисления
- Второй аргумент это ссылка &mut seq, но так как первая уже не существует, её взять можно
Это обстоятельство особенно веселит, так как уж где-где, а в передаче аргументов компилятор уж мог бы сообразить, что их вычисление не зависит от порядка передачи. Или я чего-то недопонимаю? Возможно. Но веселит всё равно.
Таким образом, нужно уметь выстраивать аргументы в правильном порядке, но этот порядок не всегда приемлем с человеческой точки зрения. Если мы хотим, чтобы первым шёл какой-то определённый аргумент, мы имеем на это право.
Тогда выход один – не подставлять вызов метода сервиса непосредственно в аргумент, а вычислить его заранее:
Выглядит как костыль, но придётся с этим жить.
Заключение
Проблема жёстко связанных ссылок в Rust стоит очень остро. Для её решения нужно зачастую пересматривать архитектуру всего приложения – и это бывает даже очень полезно – а также более тщательно следить за использованием данных: делать ссылки не на объекты, а на их части, по возможности отделять данные от методов. Это тоже хорошо.
Окидывая взглядом свой опыт программирования на Rust, могу отметить, что прошёл путь от полной беспомощности до какой-то уверенности "на троечку", но впереди ещё долгий путь. И не факт, что захочется дальше связываться.