Инкапсуляция

Инкапсуляция — принцип ООП, ограничивающий прямой доступ к внутреннему состоянию объекта. Данные и методы, работающие с ними, объединяются, а внешний код взаимодействует через публичный интерфейс.

Зачем нужно

Инкапсуляция защищает данные от некорректного изменения, скрывает детали реализации, позволяет изменять внутреннюю логику без влияния на внешний код. Это основа модульности и надёжности программ.

Где используется

Классы и модули, библиотеки API, компоненты UI, модели данных, сервисы, state management.

Предпосылки

Классы, Замыкания (Closures), this

Приватные поля (#)

ES2022 ввёл настоящие приватные поля:

class User {
  #password;
  #loginAttempts = 0;

  constructor(name, password) {
    this.name = name;         // публичное
    this.#password = password; // приватное
  }

  #hashPassword(raw) {
    // Приватный метод
    return `hashed_${raw}`;
  }

  authenticate(input) {
    this.#loginAttempts++;
    if (this.#loginAttempts > 5) {
      throw new Error('Слишком много попыток');
    }
    return this.#hashPassword(input) === this.#hashPassword(this.#password);
  }

  get attempts {
    return this.#loginAttempts;
  }
}

const user = new User('Иван', 'secret123');
console.log(user.authenticate('secret123')); // true
console.log(user.attempts); // 1

// user.#password;       // SyntaxError
// user.#hashPassword; // SyntaxError

Проверка наличия приватного поля

class Brand {
  #name;
  constructor(name) { this.#name = name; }

  static isBrand(obj) {
    return #name in obj; // true только для экземпляров Brand
  }
}

console.log(Brand.isBrand(new Brand('Test'))); // true
console.log(Brand.isBrand({ name: 'Fake' }));  // false

Замыкания для приватности

До приватных полей (#) использовали замыкания:

function createWallet(initialBalance) {
  let balance = initialBalance; // приватная переменная
  const history = ;           // приватный массив

  return {
    deposit(amount) {
      if (amount <= 0) throw new Error('Положительная сумма');
      balance += amount;
      history.push({ type: 'deposit', amount, date: new Date });
    },
    withdraw(amount) {
      if (amount > balance) throw new Error('Недостаточно средств');
      balance -= amount;
      history.push({ type: 'withdraw', amount, date: new Date });
    },
    getBalance { return balance; },
    getHistory { return [...history]; } // копия
  };
}

const wallet = createWallet(1000);
wallet.deposit(500);
wallet.withdraw(200);
console.log(wallet.getBalance); // 1300
// balance и history недоступны

WeakMap для приватных данных

const _private = new WeakMap();

class Settings {
  constructor(config) {
    _private.set(this, {
      config: { ...config },
      listeners: 
    });
  }

  get(key) {
    return _private.get(this).config[key];
  }

  set(key, value) {
    const data = _private.get(this);
    const oldValue = data.config[key];
    data.config[key] = value;
    data.listeners.forEach(fn => fn(key, value, oldValue));
  }

  onChange(listener) {
    _private.get(this).listeners.push(listener);
  }
}

const settings = new Settings({ theme: 'dark', lang: 'ru' });
settings.onChange((key, val) => console.log(`${key} = ${val}`));
settings.set('theme', 'light'); // "theme = light"
// Нет доступа к внутреннему config и listeners

Getters/Setters для валидации

class Person {
  #name;
  #age;

  constructor(name, age) {
    this.name = name; // через setter
    this.age = age;   // через setter
  }

  get name { return this.#name; }
  set name(value) {
    if (typeof value !== 'string' || value.trim().length === 0) {
      throw new Error('Имя должно быть непустой строкой');
    }
    this.#name = value.trim();
  }

  get age { return this.#age; }
  set age(value) {
    if (!Number.isInteger(value) || value < 0 || value > 150) {
      throw new Error('Возраст: целое число 0-150');
    }
    this.#age = value;
  }

  toString {
    return `${this.#name}, ${this.#age} лет`;
  }
}

const person = new Person('  Иван  ', 25);
console.log(person.name); // "Иван" (trimmed)
// person.age = -5; // Error: Возраст: целое число 0-150

Readonly свойства

class Config {
  #data;

  constructor(data) {
    this.#data = Object.freeze({ ...data });
  }

  get(key) {
    return this.#data[key];
  }

  // Нет setter — данные неизменяемы
  getAll {
    return { ...this.#data }; // копия
  }
}

const config = new Config({ apiUrl: 'https://api.example.com', timeout: 5000 });
console.log(config.get('apiUrl')); // 'https://api.example.com'
// Невозможно изменить данные

Соглашение об именовании (_)

Неформальная конвенция — не настоящая приватность:

class Service {
  constructor {
    this._cache = new Map();     // "приватное" по конвенции
    this._isInitialized = false;
  }

  _validate(data) {              // "приватный" метод
    return data != null;
  }

  process(data) {
    if (!this._validate(data)) throw new Error('Invalid');
    // ...
  }
}

// Технически _cache доступен, но конвенция говорит "не трогай"

Сравнение подходов

Подход Настоящая приватность Производительность Наследование
#private Да Отличная Не наследуется
Замыкание Да Хорошая Не применимо
WeakMap Да Хорошая Работает
_convention Нет Отличная Наследуется
Symbol Нет (Reflect) Хорошая Наследуется

Частые ошибки

1. Возврат ссылки на приватный объект

class Bad {
  #items = [1, 2, 3];
  getItems { return this.#items; } // возвращает ссылку!
}

const bad = new Bad();
bad.getItems.push(4); // мутирует приватные данные!

// Правильно — возвращать копию
class Good {
  #items = [1, 2, 3];
  getItems { return [...this.#items]; }
}

2. Приватные поля не наследуются

class Parent {
  #secret = 42;
  getSecret { return this.#secret; }
}

class Child extends Parent {
  showSecret {
    // return this.#secret; // SyntaxError!
    return this.getSecret; // Через публичный метод — OK
  }
}

3. Публичный API слишком широкий

// Плохо — всё публично
class UserService {
  cache = {};
  db = null;
  fetchFromDB(id) { /* ... */ }
  validateCache(id) { /* ... */ }
  getUser(id) { /* ... */ }
}

// Хорошо — только необходимый публичный API
class UserService {
  #cache = {};
  #db = null;
  #fetchFromDB(id) { /* ... */ }
  #validateCache(id) { /* ... */ }
  getUser(id) { /* единственный публичный метод */ }
}

Практика

  1. Создай класс Stack с приватным массивом и методами push, pop, peek, size
  2. Реализуй класс с readonly-свойствами через getters без setters
  3. Перепиши замыкание-модуль на класс с приватными полями
  4. Создай класс Validator с приватными правилами и публичным validate(data)
  5. Сравни размер: класс с #полями vs WeakMap-подход

Инкапсуляция vs сокрытие

автор принципиально разделяет:

  • Инкапсуляция — структурный принцип: объединение полей и поведения в одну абстракцию (контейнер с состоянием + методами).
  • Сокрытие — механизм защиты: невозможность извне дотянуться до внутренностей.

«Сокрытие — это в итоге всегда if. Если вы где-то пишете protected или private, это всё равно где-то происходит if. Просто от вас немножко скрыто» (8OuzIAuMfjw).

«От приличных людей не нужно никакого сокрытия делать. Подчёркивание — соглашение, не защита» (r4ReQlVtfgQ).

Это две разные идеи. Можно инкапсулировать без сокрытия (через _underscore) и сокрывать без полной инкапсуляции.

Состояние vs поведение

Инкапсуляция объединяет:

  • Состояние — поля объекта (x, y, name, cache)
  • Поведение — методы (move, draw, serialize)
  • Опционально — события (EventEmitter, observable)

struct/record без методов — частичная инкапсуляция (только данные). Полная — класс с методами.

Race condition в инкапсулированном объекте

автор (b0k8gvQ0J0Q) показывает: инкапсуляция не защищает от race condition при async-доступе. Нужны блокировки:

class Point {
  #x; #y; #lock = new Lock();
  constructor(x, y) { this.#x = x; this.#y = y; }

  async move(dx, dy) {
    await this.#lock.enter;
    this.#x += dx; this.#y += dy;
    this.#lock.leave;
  }
}

Альтернатива в браузере — Web Locks API (navigator.locks.request('key', async () => ...)).

Связанные темы

Источники