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

Неестественность ООП-моделирования
Одно из определений ООП - воссоздание или моделирование реального мира в коде, со всем свойственным поведением моделируемых объектов. Попробуем смоделировать собаку 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 что-бы что? Тогда для чего нам даны примитивы синхронизации вообще? Почему мы не должны ими пользоваться? Загадка.
Итог
Иммутабельность в ООП подходит далеко не всегда и не для всех бизнес-задач — особенно там, где важна эффективность, “живая” модель объектов, и естественная работа с идентичностью и состоянием. Поэтому выбор между иммутабельностью и изменяемостью должен основываться на специфике вашей задачи.
Комментарии (22)
amazingname
14.05.2025 06:58Следите за моими руками. У нас есть ссылка на ошейник. Из него мы можем получить ссылку на собаку (у собаки нет ссылки на ошейник). Допустим, нужно заменить ошийник. Создаем новый ошейник, устанавливаем ему ссылку на старую собаку. Профит, все иммутабельно и все оптимально.
Развиваем эту идею дальше. У нас есть катра с точками. А давайте запишем точки в односвязанный список и запомним указатель на первый элемент. Теперь, чтобы добавить новую точку, нужно ее создать, установить ей указатель на первый элемент списка и вернуть указатель на созданный новый элемент в качестве новой карты. Мы создали новую карту иммутабельно не сделав ни одного лишнего действия! И здеь нет никаких проблем с идентичностью, атомарностью, или потокобезопасностью.
rafuck
14.05.2025 06:58Только глядя на собаку нельзя понять, какой у нее ошейник. Вместо этого теперь приходится смотреть на ошейник, чтобы сказать, какая у него собака.
Мне-то ок, просто чуть уточнил последствия такой инверсии.
Gribovod
14.05.2025 06:58А можно создать отдельную структуру СобакаСОшейником и не нужно менять ни собаку ни ошейник.
amazingname
14.05.2025 06:58Можно узнать, но придется заглянуть в каждый ошейник.
Вообще, иммутабельность удобно и нужно применять в случаях когда подобная "инверсия" возможна и ничего не ломает. Остальные случаи - это дело вкуса и архитектуры. Если архитектура прямо категарично опирается на чистые функции, можно немножко пожертровать быстродействием, создавая новые объекты.
linux-over
14.05.2025 06:58Мы создали новую карту иммутабельно не сделав ни одного лишнего действия! И здеь нет никаких проблем с идентичностью, атомарностью, или потокобезопасностью.
но лютые проблемы с количеством потребляемой памяти
amazingname
14.05.2025 06:58Неа. Ни одного лишнего байта (кроме того что односвязанный список больше чем массив, это да). Прочитайте мои объяснения внимательнее еще раз.
linux-over
Проблема пропаганды иммутабельности лежит в околофилософской плоскости.
Долгое время в науке был ровно один язык формального описания и моделирования реальности: математика.
В двадцатом веке появился второй: программирование. Этот второй язык вобрал в себя математику, но очень быстро стал значительно шире неё именно потому, что свободно моделирует мутабельные объекты. Почему моделирование мутабельных объектов "зашло"? Потому что сам мир, что нас окружает, мутабельный: второй закон термодинамики это утверждает и подтверждает.
И сейчас, используя именно мутабельное программирование мы получили такие модели, которые в иммутабельной (функциональной) парадигме и запрограммировать то невозможно (теоретически возможно, практически - нет):
Искусственный интеллект (нейроны, нейросети, модели размышления, внимания и так далее)
Моделирование технологических процессов.
Базы данных, деревья поиска и т. п.
и так далее
При этом да, теоретически, иммутабельная модель позволяет запрограммировать любой алгоритм (в том числе и вышеперечисленные), и в этом смысле функциональные языки являются Тюринг-полными.
Однако для многих алгоритмов переход к иммутабельности (несмотря на полноту по Тьюрингу) означает бесконечность используемых ресурсов (самообучающийся ИИ, тому пример: бесконечный контекст vs фиксированный набор мутабельных нейронов).
Так вот, возвращаясь к рекламе функциональных языков и иммутабельности (по определению функциональный язык - язык, отличающийся от прочих именно взглядом на мутабельность - запрещающий её мягко или жёстко). Эти языки продвигают математики, которые чувствуют, что вектор внимания от их науки постепенно перетекает в программирование.
Им почему-то кажется, что это явление является нехорошим: важность математики "утрачивается" (ложное представление) - "Стул качается".
Отсюда множество статей (на хабре в том числе) на тему, что "каждый программист должен (обязан) быть математиком", про теории множеств, категорий, типов и монад, которые в программировании далеко не везде нужны и важны.
Меж тем, страхи математиков беспочвенны. Там, где она применима, там где модель можно записать в виде короткой формулы - она останется и будет развиваться, как и раньше. А для задач, выходящих за границы этого языка, будет применяться программирование - ничего страшного в этом нет.
Просто математика из всеобъемлющей науки плавно становится специализированной. С науками такое происходит постоянно - это обычный процесс.
Однако страх - штука иррациональная. И потому статьи с призывами к иммутабельности не кончатся.
Такие дела
kmatveev
И да, и нет. Да, функциональщину придумали математики и преподы программирования. Но это были математики-практики и преподы программирования, которые сами программировали. Функциональщина очень практична, поэтому находит отклик у разработчиков-практиков.
linux-over
"математики-практики", увы, подмножество слабо пересекающееся (пересекающееся! но слабо) с "программисты-практики". То есть это не люди, которые постоянно зачем-то моделируют ту или иную часть реальности: бухгалтерские, общественные, медицинские, транспортные, инженерные, какие угодно иные задачи.