Инкапсуляция, self и Struct/Data

Хороший объект прячет внутренности и показывает только нужный интерфейс. Ruby даёт уровни доступа к методам, ключевое слово self и удобные заготовки для объектов-значений.
Суть: private прячет методы от внешних вызовов, public открывает; self ссылается на текущий объект; Struct и Data.define быстро создают классы-значения без ручного initialize.

Инкапсуляция — это сокрытие деталей реализации. Снаружи объект — чёрный ящик с понятными кнопками (публичные методы), а как он устроен внутри (приватные методы, переменные) — его личное дело. Это позволяет менять внутренности, не ломая код, который пользуется объектом.

class BankAccount
  def initialize
    @balance = 0
  end

  def deposit(amount)        # публичный — точка входа
    @balance += validate(amount)
  end

  def balance = @balance

  private                    # ниже — всё приватное

  def validate(amount)       # внутренняя кухня
    raise "сумма <= 0" if amount <= 0
    amount
  end
end

acc = BankAccount.new
acc.deposit(100)
puts acc.balance        # => 100
# acc.validate(50)  => NoMethodError: private method

Разбор: self и объекты-значения

self внутри метода — это «текущий объект». Чаще всего его опускают, но он обязателен при вызове сеттера (self.balance = 10), иначе Ruby создаст локальную переменную. А для простых «контейнеров данных» вместо полноценного класса берут Struct (изменяемый) или современный Data.define (неизменяемый, с Ruby 3.2).

# Data.define — неизменяемый объект-значение (Ruby 3.2+)
Point = Data.define(:x, :y) do
  def distance_to_origin = Math.sqrt(x**2 + y**2)
end

p = Point.new(x: 3, y: 4)     # или Point.new(3, 4)
puts p.x                      # => 3
puts p.distance_to_origin     # => 5.0
# p.x = 10  => ошибка: Data заморожен, менять нельзя

# Struct — изменяемый, с перебором членов
Coord = Struct.new(:lat, :lon)
c = Coord.new(55.7, 37.6)
c.lat = 56.0                  # можно менять
puts c.to_a.inspect           # => [56.0, 37.6]

Как работает под капотом

Уровни доступа — это не «защита» в строгом смысле, а соглашение, которое Ruby проверяет при вызове. private-метод нельзя вызвать с явным получателем (obj.method) — только изнутри, на неявном self. Data.define и Struct «под капотом» сами генерируют initialize, методы доступа, ==, to_h и поддержку паттерн-матчинга (deconstruct_keys из раздела про case/in).

   объект BankAccount
   +-----------------------------+
   |  PUBLIC (видно снаружи):    |
   |    deposit, balance         |  <-- интерфейс
   |  ------------------------   |
   |  PRIVATE (только внутри):   |
   |    validate                 |  <-- реализация
   |  @balance  (состояние)      |
   +-----------------------------+
        |
   acc.deposit(100)  --> OK (public)
   acc.validate(50)  --> NoMethodError (private)

Та же идея «неизменяемый объект-значение» на Python — это frozen dataclass:

# Та же логика на Python ▶
from dataclasses import dataclass
import math

@dataclass(frozen=True)        # неизменяемый, как Data.define
class Point:
    x: float
    y: float
    def distance_to_origin(self):
        return math.sqrt(self.x**2 + self.y**2)

p = Point(3, 4)
print(p.distance_to_origin())   # 5.0

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

  • Забыть self у сеттера. Внутри метода balance = 10 создаст локальную переменную, а не вызовет сеттер. Нужно self.balance = 10.
  • Делать всё публичным. Если наружу торчат внутренние методы, любой код начнёт на них опираться, и рефакторинг станет невозможным.
  • Брать Struct там, где нужна неизменяемость. Если объект-значение не должен меняться, используйте Data.define — он замораживается автоматически.

Best practices

  • Открывайте минимальный интерфейс: всё, что не часть публичного API, делайте private.
  • Для простых неизменяемых контейнеров данных предпочитайте Data.define ручному классу — меньше кода, есть == и паттерн-матчинг из коробки.
  • Используйте self явно только там, где он нужен (сеттеры, методы класса) — в остальных местах его опускают.

Глубже: protected и зачем он нужен

Между публичными и приватными методами есть третий, менее известный уровень — protected. Разница тонкая, но важная. Приватный метод нельзя вызвать с явным получателем вообще — только на неявном self изнутри объекта. Защищённый же метод можно вызвать с явным получателем, но лишь из другого объекта того же класса (или потомка). Зачем это нужно? Классический сценарий — сравнение двух объектов одного класса по их внутреннему состоянию. Чтобы метод > у объекта a мог заглянуть в баланс объекта b, баланс b должен быть доступен — но открывать его всему миру через публичный геттер не хочется. protected — идеальный компромисс: «свои» (объекты того же класса) видят это поле друг у друга, а посторонние — нет. На практике protected используется реже private, и многие проекты обходятся без него, но знать о нём стоит: встретив его в чужом коде, вы поймёте замысел автора. А общий принцип остаётся прежним: открывайте наружу минимум, прячьте максимум, и тогда внутренности объекта можно будет менять, не ломая зависящий от него код.

Итог. Инкапсуляция прячет детали: private-методы зовутся только изнутри. self — текущий объект, обязателен для сеттеров. Struct (изменяемый) и Data.define (неизменяемый, современный) экономят код для объектов-значений, давая ==, to_h и паттерн-матчинг даром.

Проверьте себя
1. Почему внутри метода присваивание balance = 10 не вызовет сеттер?
AСеттеров в Ruby не существует
BБез self Ruby создаст локальную переменную; нужно self.balance = 10
Cbalance — зарезервированное слово
DЭто вызовет ошибку
2. Чем Data.define отличается от Struct?
AНичем
BData.define создаёт неизменяемые (замороженные) объекты-значения, а Struct — изменяемые
CData.define работает только с числами
DStruct нельзя использовать как объект-значение