Что лучше: группа изменяемых логических полей и методов, действующих на них, или явное выражение отдельных состояний и переходов между ними? Давайте рассмотрим пример из симуляции прогрессирования многостадийной инфекции.
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 } |