Мы уже касались концепции линз раньше [здесь, здесь]. Этот пост призван подробно осветить концепцию и предложить общую программную реализацию.
Линзы - это общий способ обработки общего состояния. Общее состояние - это некоторая часть состояния (переменная-член, статическая переменная или что-то еще), которая изменяется более чем одним процессом (модулем, частью кода, потоком и т. Д.) И наблюдается одним или несколькими процессами. Термин «общее состояние» часто используется для описания вещей, измененных несколькими процессами (потоками) операционной системы. Это также применимо к ситуациям, когда внутренние процессы программы можно рассматривать как неупорядоченные; например, когда процессы зависят от входящих событий пользовательского ввода или при использовании механизма квантования времени (как в функциях обновления для каждого объекта в видеоигре). Вот три примера общего состояния в видеоигре:
- Игра поставлена на паузу? Это значение может быть изменено кат-сценой, учебником, отключенным контроллером или меню.
- Какие действия может выполнять игрок (например, использовать гранату, произносить заклинание, прыгать, летать)? Это значение может изменяться в зависимости от предметов, прогресса, руководств и т. Д.
- Сколько урона наносит игрок? Это значение может быть изменено различными предметами, временными усилениями, повышением уровня и т. Д.
Ключевым моментом здесь является то, что значения или наборы значений изменяются несколькими процессами в вашем приложении, а затем считываются одним или несколькими процессами. Этот пример кода иллюстрирует тип общего состояния ошибки, которое обычно приводит к:
class Game{ static bool paused; } … tutorial_prompt(){ Game.paused = true; show_tutorial().then( () => Game.paused = false ); } … clicked_menu(){ Game.paused = true; open_menu(); } clicked_exit(){ Game.paused = false; close_menu(); }
Здесь у нас есть обучающая подсказка, которая ставит игру на паузу; у нас также есть функции, которые открывают и закрывают меню, которые ставят игру на паузу или возобновляют. Что произойдет, если меню откроется , а затем начнется обучение? Что ж, когда окно обучения закрывается, игра перестанет приостанавливаться, а меню останется открытым. Это надуманный пример, но, тем не менее, очень распространенная проблема. Простое решение может выглядеть примерно так:
tutorial_prompt(){ Game.paused = true; show_tutorial().then( () => Game.paused = Menu.open? true:false ); } … clicked_menu(){ Game.paused = true; open_menu(); } clicked_exit(){ Game.paused = Tut.open? true:false; close_menu(); }
Мы проверяем, было ли открыто меню, и не возвращаем для паузы значение false, если оно было открыто. Легкий.
Но ждать! Теперь обучающая система знает о том, что в игре есть меню и наоборот. Нам также пришлось сделать открытое меню и учебное пособие общедоступными свойствами. Если мы удалим одну из этих функций из игры или существенно изменим их, нам придется отредактировать остальные. Добавьте кат-сцену или систему ввода, которая также может ставить игру на паузу, и вы увидите, что у нас есть проблема (n bool проверяет при каждом чтении паузы). Это называется связью, и это пупей (очень технический термин) для вашего проекта.
Вот еще один пример, который часто встречается в играх: сколько урона наносит игрок? Часто мы начинаем с описания ущерба как простого числа с плавающей запятой. Затем, когда вы получаете предмет, вы добавляете к нему или умножаете его. Довольно скоро мы понимаем, что порядок, в котором применяются сложения и множители, имеет значение, и затем мы начинаем сортировать предметы по типу урона, который они наносят. Затем мы решаем, что вы также можете получить баффы, поэтому нам нужно убедиться, что баффы также отсортированы в этот список и так далее.
Давайте посмотрим, как линзы решают обе эти проблемы. Концептуально в обоих этих случаях вы имеете дело со значением (логическим значением, списком, числом с плавающей запятой или чем-то еще), у которого есть несколько наблюдателей (процессы, которым требуется это значение в произвольный момент времени) и несколько мутаторов (процессы, которые постоянно влияют на значение в произвольное время).
Некоторые из существующих решений для работы с общим состоянием - это семафоры (блокировки использования ресурсов) и реактивное программирование.
Семафоры работают очень хорошо и эффективно, но работают только с проблемами, которые можно смоделировать как блокирующие ресурсы (например, проблема паузы, называемая идемпотентными проблемами). Они не работают для задач, в которых порядок действий мутатора имеет значение, или для задач, в которых значение может быть изменено произвольным образом с помощью мутатора.
При реактивном программировании модули получают события всякий раз, когда значение изменяется, а наблюдатели получают уведомления (и обычно ссылку на то, кто его изменил). Обычно наблюдатели затем проводят внутреннюю бухгалтерию, чтобы отслеживать состояние переменной. Такой код часто оказывается намного более сложным и имеет ряд переменных, локальных по отношению к модулям, которые тем или иным образом отслеживают общее состояние.
Так что же такое линза?
Назовем часть общего состояния линзированным значением. Представьте линзу как физический объект, а изменяющиеся модули - как линзы. Когда вы смотрите через линзу на объект, он выглядит иначе, чем сам по себе. Более того, вы можете складывать несколько линз в разных конфигурациях, и все они вносят свой вклад в разное представление объекта.
Порядок линз и то, как линза трансформирует состояние, зависит от конкретного приложения. Линзы обычно могут быть реализованы как упорядоченный набор преобразований переменной состояния. Многие конкретные случаи можно реализовать более эффективно.
Как? Вот общая реализация C #:
using UnityEngine; using System.Collections; using System.Collections.Generic; using System; // This is the part mutating modules apply to the LensedValue public class Lens<T>{ public int priority; public Func<T,T> transformation; public Lens(int priority, Func<T,T> transformation){ this.priority = priority; this.transformation = transformation; } }
Затем у нас есть сам класс LensedValue:
// This class represents the shared state and has the GetValue() // method that returns the value rendered through the lenses. class LensedValue<T> { T value; List<Lens<T>> lenses; public LensedValue(T initialValue){ this.value = initialValue; this.lenses = new List<Lens<T>>(); } public LensToken AddLens(Lens<T> lens){ lenses.Add(lens); lenses.Sort((x,y)=> x.priority - y.priority); return new LensToken( () => lenses.Remove(lens) ); } public T GetValue(){ T tmp = value; foreach(var lens in lenses){ tmp = lens.transformation(tmp); } return tmp; } }
и LensToken для снятия линз, когда они больше не нужны:
// LensToken is a simple class used to keep a token to remove // a Lens from a LensedValue class LensToken{ Action action; public LensToken(Action action){ this.action = action; } public void Remove(){ action(); } }
А вот пример того, как вы можете использовать это для игрушечной задачи отслеживания нанесенного игроком урона, о которой мы говорили ранее:
class Player{ public static LensedValue<float> damage = new LensedValue<float>(10); } public class LensExampleDamage : MonoBehaviour { enum DamageType{ Base = 0, Multiplier = 1, Percentage = 2 } void Start () { Debug.Log(Player.damage.GetValue()); // 10 var multDmgLensToken = Player.damage.AddLens( new Lens<float>( (int)DamageType.Multiplier, (dmg)=> dmg * 2) ); Debug.Log(Player.damage.GetValue()); // 20 var baseDmgLensToken = Player.damage.AddLens( new Lens<float>( (int)DamageType.Base, (dmg)=> dmg + 5) ); Debug.Log(Player.damage.GetValue()); // 30 var multDmgLensToken2 = Player.damage.AddLens( new Lens<float>( (int)DamageType.Multiplier, (dmg)=> dmg * 1.5f) ); Debug.Log(Player.damage.GetValue()); // 45 multDmgLensToken.Remove(); Debug.Log(Player.damage.GetValue()); // 22.5 } }
Конечно, это игрушечный пример (два других обсуждаемых выше примера - здесь и здесь), но он показывает, насколько легко несколько модулей могут изменять и читать LensedValue, не беспокоясь друг о друге.
Мы можем сделать несколько очевидных расширений:
- использовать атомарные операции для добавления / удаления линз, поэтому многие потоки могут добавлять линзы без блокировки чтения или повреждения данных
- добавить дополнительное кэширование LensedValue для линз, которые используют только чистые функции для своих преобразований.
- добавьте специальные типы LensedValue (например, IdempotentLensedValue) для поддержки определенных оптимизаций (т.е. если порядок рендеринга линз не имеет значения или мутации можно заменить простым счетчиком, как в примере паузы).
Подводя итог, мы рассмотрели Lenses как способ разделения общего состояния между программными процессами и несколько примеров из разработки игр. Вам предстоит внести множество улучшений и оптимизаций, и я буду рад, если вы поделитесь своими с сообществом!
Если у вас есть какие-либо комментарии или вопросы, не стесняйтесь размещать здесь или писать в Twitter, а если вам нужна помощь с техническим проектом, проверьте Twisted Oak.