Мы уже касались концепции линз раньше [здесь, здесь]. Этот пост призван подробно осветить концепцию и предложить общую программную реализацию.

Линзы - это общий способ обработки общего состояния. Общее состояние - это некоторая часть состояния (переменная-член, статическая переменная или что-то еще), которая изменяется более чем одним процессом (модулем, частью кода, потоком и т. Д.) И наблюдается одним или несколькими процессами. Термин «общее состояние» часто используется для описания вещей, измененных несколькими процессами (потоками) операционной системы. Это также применимо к ситуациям, когда внутренние процессы программы можно рассматривать как неупорядоченные; например, когда процессы зависят от входящих событий пользовательского ввода или при использовании механизма квантования времени (как в функциях обновления для каждого объекта в видеоигре). Вот три примера общего состояния в видеоигре:

  • Игра поставлена ​​на паузу? Это значение может быть изменено кат-сценой, учебником, отключенным контроллером или меню.
  • Какие действия может выполнять игрок (например, использовать гранату, произносить заклинание, прыгать, летать)? Это значение может изменяться в зависимости от предметов, прогресса, руководств и т. Д.
  • Сколько урона наносит игрок? Это значение может быть изменено различными предметами, временными усилениями, повышением уровня и т. Д.

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

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.