GRASP. Часть 1 - Информационный эксперт

Привет!

Сегодня я хочу поговорить про GRASP. В то время как многие знакомы с SOLID, GRASP, хотя и известен, мало кто в геймдеве воспринимает его всерьёз (или хотя бы знают о нем). GRASP расшифровывается как общие шаблоны распределения ответственностей. Самые часто упоминаемые принципы GRASP известны гораздо шире, чем сам список, в который они включены. Это знаменитые слова про низкую связность и высокое зацепление.

Принципы GRASP
Принципы GRASP

Лично я считаю, что знание GRASP не менее важно, чем принципы SOLID. Ведь хотя SOLID и сосредоточен на создании масштабируемого и поддерживаемого кода, но они гораздо более абстрактны по сравнению с GRASP. Как пример, несколько паттернов GoF вытекают напрямую из принципов GRASP, в отличие от SOLID.

Другими словами, если SOLID говорит о том, что делать, GRASP объясняет, как это делать на практике.

Поэтому, во-первых, небольшой дисклеймер: не воспринимайте это как соревнование между GRASP и SOLID. Оба набора принципов дополняют друг друга и вместе дают гораздо больше понимания о том, какой код можно условно считать плохим или хорошим.

Итак, первый принцип – информационный эксперт. Он говорит о том, что ответственность за выполнение задачи должна быть возложена на класс, который имеет всю необходимую информацию для этого. Например, если нам нужно построить дом, мы идём к архитектору, который точно знает, как его строить.

Если вам лень читать дальше, смотрите этот же разбор на Youtube. А с остальными давайте перейдём к игровому примеру!

Сам репозиторий с примером можете найти у меня на GitHub

Инвентарь
Инвентарь

Слева у нас предметы, которые надеты на персонажа, а справа – предметы, которые просто сложены в его инвентаре. У предметов есть две характеристики: шарм и урон. Если у предмета в сумме эти показатели больше, чем у надетых, то такой предмет отображается зеленой стрелкой. Предметы можем перекладывать туда-обратно, удалять по кнопке корзиной и крафтить новые кнопкой сверху. Теперь посмотрим, что в этом примере реализовано плохо с точки зрения первого принципа, и попробуем это исправить.

public class Player { public event Action Updated; public readonly List<Item> UsedItems = new(); public readonly List<Item> StoredItems = new(); public void InvokeUpdate() => Updated?.Invoke(); }

В первую очередь, зайдём в класс Player. Вот видим, что он у нас довольно пустой и “глупый”. У него просто есть список предметов, с которыми каждый внешний класс работает так, как ему вздумается. То есть кто-то кладёт, кто-то убирает, кто-то просто по ним пробегает. У нас есть также событие обновления, на которое подписаны все наши вьюшки, и его надо не забывать вызывать при каждом изменении.

Несогласованные и повторяющиеся изменения этих списков происходят в четырех местах. Также необходимость вызова InvokeUpdate() показывает, что каждому внешнему пользователю этого класса нужно точно знать его реализацию, что довольно неправильно. Например, чтобы купить продукты в какой-нибудь пятерочке, не нужно знать все особенности логистики и работы магазина - кладешь в корзину и идешь на кассу.

Поэтому давайте это исправим. Начнём с UsedItemView.

public class UsedItemView : MonoBehaviour { [SerializeField] private Button _replace; [SerializeField] private Text _name; [SerializeField] private Text _charm; [SerializeField] private Text _damage; private Player _player; private Item _item; [Inject] private void Construct(Player player) => _player = player; private void Start() => _replace.onClick.AsObservable().Subscribe(_ => Move()).AddTo(this); public void SetItem(Item item) { _item = item; _name.text = item.Name; _charm.text = $"Charm +{item.Charm}"; _damage.text = $"Damage +{item.Damage}"; } private void Move() { _player.UsedItems.Remove(_item); _player.StoredItems.Add(_item); _player.InvokeUpdate(); } }

Это очень высокоуровневая сущность, которая работает непосредственно с пользователем. И мы не должны в ней закапываться или даже знать детали реализации класса Player. Что мы должны сделать, так это сказать классу "положи этот предмет в инвентарь". Чтобы его метод Start() стал выглядеть вот так:

private void Start() => _replace.onClick.AsObservable() .Subscribe(_ => _player.Store(_item)) .AddTo(this);

А класс Player добавился метод Store:

public void Store(Item item) { UsedItems.Remove(item); StoredItems.Add(item); Updated?.Invoke(); }

Теперь давайте избежим беготни по ссылкам и поиска "а где же еще мы используем поля и методы класса Player". И следующий рефакторинг начнем не от внешнего поведения, где мы смотрим на верхнеуровневый класс и оставляем в нем только то что не ломает его абстракцию, а от внутреннего. То есть, внутри класса Player сразу определим, к чему мы не хотим давать доступ извне. Вытащим наружу вместо самих листов интерфейс IReadOnlyList и уберем метод InvokeUpdate, так как событие теперь будем вызывать только изнутри внутри будущих методов.

public class Player { public event Action Updated; public IReadOnlyList<Item> UsedItems => _usedItems; public IReadOnlyList<Item> StoredItems => _storedItems; private readonly List<Item> _usedItems = new(); private readonly List<Item> _storedItems = new(); }

Таким образом, изменив внешний интерфейс класса, у нас сразу видно в консоли несколько ошибок. Это те места, где мы напрямую использовали списки предметов. Все эти места, где была ошибка, поправим, заменив на методы.

Ошибки при рефакторинге
Ошибки при рефакторинге

После всех изменений, класс Player будет выглядеть примерно так:

public class Player { public event Action Updated; public IReadOnlyList<Item> UsedItems => _usedItems; public IReadOnlyList<Item> StoredItems => _storedItems; private readonly List<Item> _usedItems = new(); private readonly List<Item> _storedItems = new(); public void Store(Item item) { _usedItems.Remove(item); _storedItems.Add(item); Updated?.Invoke(); } public void Add(Item item) { _storedItems.Add(item); Updated?.Invoke(); } public void Use(Item item) { _storedItems.Remove(item); _usedItems.Add(item); Updated?.Invoke(); } public void Destroy(Item item) { _storedItems.Remove(item); Updated?.Invoke(); } public bool IsItemBetterThanUsed(Item item) { Item toCompare = _usedItems.Find(x => x.Slot == item.Slot); if (toCompare == null) return true; int originStatsSum = item.Charm + item.Damage; int toCompareStatsSum = toCompare.Charm + toCompare.Damage; return originStatsSum > toCompareStatsSum; } }

А весь внешний код не будет низкоуровнево использовать его реализацию.

public class StoredItemView : MonoBehaviour { [SerializeField] private Button _replace; [SerializeField] private Button _destroy; [SerializeField] private Text _name; [SerializeField] private Image _betterMark; private Player _player; private Item _item; [Inject] private void Construct(Player player) => _player = player; private void Start() { _replace.onClick.AsObservable().Subscribe(_ => _player.Use(_item)).AddTo(this); _destroy.onClick.AsObservable().Subscribe(_ => _player.Destroy(_item)).AddTo(this); } public void SetItem(Item item) { _item = item; _name.text = item.Name; _betterMark.enabled = _player.IsItemBetterThanUsed(_item); } }

Вот как раз хороший пример того, как уменьшится внешний код, код, отвечающий за логику отображения. Он становится сразу заметно меньше и более читабельным. То есть, когда мы заходим сюда, то видим, какие элементы в классе есть, и что с ними происходит. После рефакторинга этот класс уменьшился примерно в 2 раза. Так как раньше внутри него была логика добавления и проверки, лучше ли предмет того, что надет на игрока (метод IsItemBetterThanUsed())

Таким образом, мы поправили наш код, используя первый принцип GRASP – информационного эксперта. Про принцип Создатель поговорим в следующей статье.

Подписывайтесь на мой телеграм-канал, чтобы узнать еще больше о разработке! И спасибо!

55
Начать дискуссию