Что лучше: группа изменяемых логических полей и методов, действующих на них, или явное выражение отдельных состояний и переходов между ними? Давайте рассмотрим пример из симуляции прогрессирования многостадийной инфекции.
1. Дизайн, скрытый в примитивных изменчивых полях и методах
Следующий класс с несколькими взаимосвязанными изменяемыми (общедоступными) полями, которые я должен был заполнить методами для переходов между их состояниями, сделал меня по-настоящему непростым (var — изменяемое поле, val — неизменяемый):
|
01
02
03
04
05
06
07
08
09
10
|
class Person (val id: Int) { var infected = false var sick = false var immune = false var dead = false // to complete with simulation logic // [infection progression]} |
Поля хранят состояние прогресса инфекции. Они зависят друг от друга — например, когда больна , человек также должен быть заражен , в то время как когда она мертва , она не должна быть иммунной .
Прогрессирование инфекции: здоровое -> инфицированное и заразное с вероятностью 40% (но пока нездорово) -> на 6-й день заметно больное -> на 14-й день умирает с вероятностью 25% -> если не умерло становится иммунным в 16-й день — не заметно болен, но все еще заразен -> здоров в 18-й день.
Проблема, связанная с сохранением состояния в куче полей, заключается в том, что нет явного выражения этих правил, и что оно открывает для дефектов, таких как присвоение больному значения « истина», в то время как забывается устанавливать и зараженное . Это также трудно понять. Вы можете изучить правила, изучив методы, которые изменяют поля, но это требует больших усилий, нелегко различить случайные детали реализации и намеренный дизайн, и этот код не предотвращает ошибки, подобные описанному.
Плюсы:
- Знакомый
- Легко оформить
Минусы:
- Правила распространения заражения не ясны из кода (ну, это было бы не так, даже если бы методы были фактически показаны), то есть он плохо передает понятия и правила предметной области.
- Значения нескольких полей должны быть синхронизированы, не гарантируя, что это приводит к дефектам
- Приводит к спагетти-коду
2. Явное и недопустимое выражение состояний и переходов
Я бы предпочел сделать правила центральной частью проекта, а не основывать их на деталях реализации того, как мы сохраняем состояние (т. Е. На множестве логических значений для управления). Другими словами, я хочу раскрыть дизайн, скрытый в этом. Я хочу иметь четкое представление о состояниях — Здоровый, Инкубационный, Больной, Мертвый, Иммунный — с четкими переходами между ними, и я хочу, чтобы эти состояния явно несут вторичную информацию о том, заразен ли человек или нет, и является ли это видимым или нет.
Один способ выразить этот дизайн явно это:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
def randomBelow = ... /** When should the health state change and what to */class HealthChange(val time: Int, val newHealth: Health) { def immediate = (time == 0)}/** The root of the health hierarchy; the health (i.e. infection) evolves in stages */sealed abstract class Health(val infectious: Boolean, val visiblyInfectious: Boolean) { def evolve(): Option[HealthChange] // returns None if no further change possibly/expected}/** In some stages the person is infected but it isn't visible*/sealed abstract class CovertlyInfected extends Health(infectious = true, visiblyInfectious = false) {}/** In other stages the person is infected and it is clearly visible*/sealed abstract class VisiblyInfected extends Health(infectious = true, visiblyInfectious = true) {}case object HEALTHY extends Health(infectious = false, visiblyInfectious = false) { def evolve() = // 40% chance of getting infected, triggered on meeting an infected person if (randomBelow(101) <= 40/*%*/) Some(new HealthChange(0, INCUBATIOUS)) else None // no change, stays healthy}case object INCUBATIOUS extends CovertlyInfected { def evolve() = // After 6 days if infection without visible effects becomes sick Some(new HealthChange(6, SICK))}case object SICK extends VisiblyInfected { def evolve() = // die with 25% on day 14 or stays sick for 2 more days, then immune if (randomBelow(101) <= 25/*%*/) Some(new HealthChange(14 - 6, DEAD)) else Some(new HealthChange(16 - 6, IMMUNE))}case object DEAD extends VisiblyInfected { def evolve() = None // Dead people stay dead}/** The symptoms have disappeared but the person can still infect others for 2 more days */case object IMMUNE extends CovertlyInfected { def evolve() = Some(new HealthChange(18 - 16, HEALTHY))}class Person (val id: Int, var health: Health = HEALTHY) { def tryInfect() { // upon meeting an infected person if (health != HEALTHY) throw new IllegalStateException("Only healthy can be infected") health.evolve().foreach( healthChng => setHealth(healthChng.newHealth) // infection happens immediately ) } /** Set the new health stage and schedule the next health change, if any */ def setHealth(h: Health) { this.health = h h.evolve().foreach(hNext => { if (hNext.immediate) setHealth(hNext.newHealth) else afterDelay(hNext.time) {setHealth(hNext.newHealth)} }) }} |
Pros
- Правила и этапы заражения теперь явные и первоклассные члены кода; Доменно-управляемый дизайн на практике
- Переходы между состояниями ясны, явны, и мы не можем перевести человека в недопустимое состояние (при условии, что мы правильно определили переходы)
- Нам больше не нужно синхронизировать состояние нескольких переменных
Cons
- Код, вероятно, длиннее, чем набор полей bool и методов для перехода между их состояниями.
- Это может показаться сложным, потому что вместо одного класса и нескольких методов мы внезапно имеем иерархию классов; но на самом деле это предотвращает сложность исходного кода спагетти, поэтому, хотя и не «легкое» для понимания, оно «простое», согласно Р. Хики
Вывод
Я часто сталкиваюсь с подобным кодом, особенно в старых унаследованных приложениях, которые были разработаны в соответствии с меняющимися бизнес-потребностями с уделением основного внимания «функциям» без соответствующего обновления / рефакторинга лежащего в основе проекта. Честно говоря, это ад. Низкоуровневый код, работающий с несколькими взаимозависимыми полями (в классе, который, вероятно, также имеет несколько несвязанных полей или полей, которые зависят от них только в определенных случаях использования), является раем для дефектов, которые можно скрыть и размножить. И это трудно понять, так как дизайн — правила, концепции и намерения — не были сделаны явными (пока). И трудно понять, значит трудно изменить.
Поэтому важно регулярно просматривать ваш код и выявлять скрытый в нем дизайн, скрывая детали реализации низкого уровня и делая ключевые понятия, состояния, переходы, правила и т. Д. Первоклассными членами базы кода, чтобы читать код ощущается как общение и обучение, а не как археология.
Обновления
Обновление 1
Код на основе Enum и Java от Jerrinot, включенный сюда для удобства чтения:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
public enum Health { HEALTHY(false, false, false, false), INCUBATIOUS(true, false, false, false), SICK(true...), DEAD(....); private boolean infected; private boolean sick; private boolean immune; private boolean dead; private Health(boolean infected, boolean sick, boolean immune, boolean dead) { this.infected = infected; this.sick = sick; this.immune = immune; this.dead = dead; } public boolean isInfected() { return infected; } public boolean isSick() { return sick; } public Health evolve() { switch (this) { case HEALTHY: return computeProbabability..() ? INCUBATIOUS : HEALTHY;[....] } }} |
Обновление 2
Версия Джеррино в Скале:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
sealed class Health(val infectious: Boolean, val visiblyInfectious: Boolean) { def evolve(implicit randomGenerator: RandomIntGenerator, config: MySimConfig): Option[HealthChange] = this match { case Healthy => if (randomGenerator.randomBelow(101) <= config.transmissibilityPct) Some(new HealthChange(0, Incubatious)) else None case Incubatious => Some(new HealthChange(6, Sick)) case Sick => if (randomGenerator.randomBelow(101) <= config.mortalityPct) Some(new HealthChange(14 - 6, Dead)) else None case Dead => None case Immune => Some(new HealthChange(18 - 16, Healthy)) case Vaccinated => None }}// infected? visibly?object Healthy extends Health(false, false)object Incubatious extends Health(true, false)object Sick extends Health(true, true)object Dead extends Health(true, true)object Immune extends Health(true, false)object Vaccinated extends Health(false, false) |
«Примитивная» версия с почти полным кодом, исключая информацию о том, что происходит, когда:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
// P.S.: Excuse the underscores ...class Person (val id: Int) { // Simplified primitive version, assuming the information when should the evolution // happen is handled somewhere else def evolveHealth(): Unit = { if (!_infected) if (randomGenerator.randomBelow(101) <= config.transmissibilityPct) this._infected = true if (_infected && !_sick && !_immune) this._sick = true if (_sick) if (randomGenerator.randomBelow(101) <= config.mortalityPct) this._dead = true else this._unDead = true // did not die but still sick if (_unDead) this._immune = true this._sick = true if (_immune) this._immune = false this._infected = false } private var _unDead = false private var _infected = false private var _sick = false private var _immune = false private var _dead = false} |