golang

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

  • четверг, 15 мая 2025 г. в 00:00:08
https://habr.com/ru/articles/909228/

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

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

Одно из определений ООП - воссоздание или моделирование реального мира в коде, со всем свойственным поведением моделируемых объектов. Попробуем смоделировать собаку 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.86% Да8
51.43% Нет18
25.71% По возможности пишу иммутабельный код, но в основном нет.9
Проголосовали 35 пользователей. Воздержались 3 пользователя.