Иммутабельность в ООП — что ты такое?

от автора

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

Неестественность ООП-моделирования

Одно из определений ООП — воссоздание или моделирование реального мира в коде, со всем свойственным поведением моделируемых объектов. Попробуем смоделировать собаку c ошейником из реального мира, и попробуем поменять ошейник не меняя состояния.

type DogCollar struct { color string }  func NewDogCollar(color string) DogCollar { return DogCollar{color: color} }  type Dog struct { nickName  string dogCollar DogCollar }  func NewDog(nickName string, dogCollar DogCollar) Dog { return Dog{nickName: nickName, dogCollar: dogCollar} }  func (d Dog) WithCollar(dogCollar DogCollar) Dog { return Dog{dogCollar: dogCollar, nickName: d.nickName} }  func main() { dog := NewDog("Вася", NewDogCollar("red")) dog = dog.WithCollar(NewDogCollar("blue")) }

Так, подождите, я только что создал новую собаку? Что я должен думать глядя на метод WithCollar? Почему я создаю новую собаку вместо того, чтобы просто поменять ей ошейник? Это контринтуитивно!
ООП призван делать код естественным и интуитивно понятным, но иммутабельное поведение вносит неестественность для реального мира. Пример гипертрофирован, но представьте что будет, если мы оперируем сложными и опасными объектами на своей работе, например банковским счётом?

Производительность

А что на счёт нее? Давайте попробуем создать карту, на которой будут размещены точки гео-объектов. Естественно мы будем делать это иммутабельно:

package main  import "slices"  type GeoPoint struct { lat  float64 long float64 }  func NewGeoPoint(lat, long float64) GeoPoint { return GeoPoint{lat: lat, long: long} }  type Map struct { geoPoints []GeoPoint }  func NewMap() Map { return Map{} }  func (m Map) WithNewPoint(point GeoPoint) Map { return Map{geoPoints: append(slices.Clone(m.geoPoints), point)} }  func main() { geoMap := NewMap()     geoMap = geoMap.WithNewPoint(NewGeoPoint(1, 1)). WithNewPoint(NewGeoPoint(2, 2)). WithNewPoint(NewGeoPoint(3, 3)). WithNewPoint(NewGeoPoint(4, 4)) }

Так, так…Подождите. Вы хотите сказать, что при добавлении новой точки на карту, я создаю новую карту и каждый раз аллоцирую и копирую память в куче, 4 раза!? Интересно, что скажет garbage collector и оперативная память на этот счёт, например если у нас миллион точек?

Идентичность

А как дела обстоят тут? Давайте представим что у нас есть коллекция пользователей, которых мы планируем менять. Знаю, вы скажете что этот код можно написать без указателей, но это не всегда возможно! Это просто демонстрация.

package main  import ( "fmt" )  // Иммутабельная структура пользователя type User struct { ID   int Name string Age  int }  // "Изменяем" пользователя (создаём новый объект) func (u User) WithAge(newAge int) User { return User{ ID:   u.ID, Name: u.Name, Age:  newAge, } }  func main() { // Создаём пользователя original := User{ID: 1, Name: "Ivan", Age: 25}  // "Изменяем" возраст — появится новый User updated := original.WithAge(26)  // Сравниваем ссылки (Go всегда копирует структуры, но для примера) fmt.Printf("original == updated: %v\n", original == updated) // false по Age, true по ID+Name  // Проблема: если мы используем карты/сеты по ссылке — это уже новый объект users := map[*User]string{ &original: "active", }  // Спрашиваем по другому объекту — получаем false _, ok := users[&updated] // false, потому что это СОВЕРШЕННО другой объект в памяти fmt.Printf("users[&updated] exists: %v\n", ok)  // Даже если User.ID тот же — Go различает эти объекты как разные ссылки }

Атомарность изменений

Один из аргументов за написание иммутабельного когда является атомарность изменений. Продемонстрирую пример:

type Developer struct { name  string email string grade string }  // Иммутабельный способ func (d Developer) UpGradeImmutable() (Developer, error) { if //some logic// { return Developer{}, fmt.Errorf("upgrade error") }     //Мы создали обьект атомарно, без промежуточных состояний return Developer{ name:  d.name, email: d.email, grade: "senior", }, nil }  // Мы вернули ошибку, но изменили состояние перед возвратом. func (d Developer) UpGrade() error { d.grade = "senior" if //some logic// { return fmt.Errorf("upgrade error") } }

Иммутабельный метод преподносится, как более безопасный и менее подверженный логическим ошибкам способ программирования. На мой взгляд плата за такой код очень велика, ввиду проблем описанных выше.

thread-safety

Потокобезопасность — так же позиционируется как плюс иммутабельного подхода.

Вернемся к карте с гео-обьектами, опишем структуру с изменяемым состоянием да ещё и потокобезопасно:

package main  import ( "sync" )  type GeoPoint struct { lat  float64 long float64 }  func NewGeoPoint(lat, long float64) GeoPoint { return GeoPoint{lat: lat, long: long} }  type Map struct { mu sync.RWMutex geoPoints []GeoPoint }  func NewMap() Map { return Map{} }  func (m *Map) AddPoint(point GeoPoint) { m.mu.Lock() defer m.mu.Unlock() m.geoPoints = append(m.geoPoints, point) }  func (m *Map) FindGeoObject(lat, long float64) GeoPoint { m.mu.RLock() defer m.mu.RUnlock() //some logic// }

А теперь иммутабельно:

package main  import "slices"  type GeoPoint struct { lat  float64 long float64 }  func NewGeoPoint(lat, long float64) GeoPoint { return GeoPoint{lat: lat, long: long} }  type Map struct { geoPoints []GeoPoint }  func NewMap() Map { return Map{} }  func (m Map) WithNewPoint(point GeoPoint) Map { return Map{geoPoints: append(slices.Clone(m.geoPoints), point)} }  func (m *Map) FindGeoObject(lat, long float64) GeoPoint { //some logic// }

В последнем варианте мы не используем Mutex. Но в чём плюс данного подхода? Мы не используем Mutex что-бы что? Тогда для чего нам даны примитивы синхронизации вообще? Почему мы не должны ими пользоваться? Загадка.

Итог

Иммутабельность в ООП подходит далеко не всегда и не для всех бизнес-задач — особенно там, где важна эффективность, “живая” модель объектов, и естественная работа с идентичностью и состоянием. Поэтому выбор между иммутабельностью и изменяемостью должен основываться на специфике вашей задачи.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Вы пишете иммутабельный код?

25.93% Да7
51.85% Нет14
22.22% По возможности пишу иммутабельный код, но в основном нет.6

Проголосовали 27 пользователей. Воздержались 3 пользователя.

ссылка на оригинал статьи https://habr.com/ru/articles/909228/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *