Иммутабельность в ООП — что ты такое?
- четверг, 15 мая 2025 г. в 00:00:08
Я довольно давно работаю в парадигме ООП, и на протяжении всей своей карьеры, из различных закоулков, слышу одну странную на мой взгляд мысль о том, что иммутабельность в ООП - это чуть ли не серебряная пуля, которая разрешит большинство ваших проблем. Давайте попробуем разобраться, так ли это на самом деле.
Одно из определений ООП - воссоздание или моделирование реального мира в коде, со всем свойственным поведением моделируемых объектов. Попробуем смоделировать собаку 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")
}
}
Иммутабельный метод преподносится, как более безопасный и менее подверженный логическим ошибкам способ программирования. На мой взгляд плата за такой код очень велика, ввиду проблем описанных выше.
Потокобезопасность - так же позиционируется как плюс иммутабельного подхода.
Вернемся к карте с гео-обьектами, опишем структуру с изменяемым состоянием да ещё и потокобезопасно:
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 что-бы что? Тогда для чего нам даны примитивы синхронизации вообще? Почему мы не должны ими пользоваться? Загадка.
Иммутабельность в ООП подходит далеко не всегда и не для всех бизнес-задач — особенно там, где важна эффективность, “живая” модель объектов, и естественная работа с идентичностью и состоянием. Поэтому выбор между иммутабельностью и изменяемостью должен основываться на специфике вашей задачи.