Data 클래스

Data 클래스

값처럼(value-alike) 쓰이는 단순한 클래스를 손쉽게 정의하고 싶다면 Data 클래스가 제격이에요. 필드를 나열하면 그걸로 끝, 바로 값 객체가 만들어져요.

가장 간단한 사용 예시부터 볼게요:

Measure = Data.define(:amount, :unit)

# 위치 인자 생성자가 제공돼요
distance = Measure.new(100, 'km')
#=> #<data Measure amount=100, unit="km">

# 키워드 인자 생성자도 제공돼요
weight = Measure.new(amount: 50, unit: 'kg')
#=> #<data Measure amount=50, unit="kg">

# 객체를 만드는 또 다른 형태:
speed = Measure[10, 'mPh']
#=> #<data Measure amount=10, unit="mPh">

# 키워드 인자로도 동작해요:
area = Measure[amount: 1.5, unit: 'm^2']
#=> #<data Measure amount=1.5, unit="m^2">

# 인자 접근자(accessor)가 제공돼요:
distance.amount #=> 100
distance.unit #=> "km"

만들어진 객체에는 합리적인 == 연산자, to_h 해시 변환, 그리고 패턴 매칭에 쓰이는 deconstruct/deconstruct_keys 정의가 함께 마련돼 있어요.

::define 메서드는 선택적 블록을 받아서, 새로 정의된 클래스의 컨텍스트에서 그 블록을 평가해요. 이 덕분에 추가 메서드를 정의할 수 있죠:

Measure = Data.define(:amount, :unit) do
  def <=>(other)
    return unless other.is_a?(self.class) && other.unit == unit
    amount <=> other.amount
  end

  include Comparable
end

Measure[3, 'm'] < Measure[5, 'm'] #=> true
Measure[3, 'm'] < Measure[5, 'kg']
# comparison of Measure with Measure failed (ArgumentError)

Data는 멤버 writer나 열거자(enumerator)를 제공하지 않아요. 불변의 원자적인 값들을 담아 두기 위한 것이기 때문이죠. 다만 멤버 중 일부가 가변 클래스라면 Data가 추가로 불변성을 강제하지는 않아요:

Event = Data.define(:time, :weekdays)
event = Event.new('18:00', %w[Tue Wed Fri])
#=> #<data Event time="18:00", weekdays=["Tue", "Wed", "Fri"]>

# #time= 이나 #weekdays= 접근자는 없지만, 변경은 여전히 가능해요:
event.weekdays << 'Sat'
event
#=> #<data Event time="18:00", weekdays=["Tue", "Wed", "Fri", "Sat"]>

비슷한 개념으로 Struct도 있어요. 다만 Struct는 더 컨테이너스러운 API를 제공해서 객체 내용을 바꾸고 열거할 수 있어요.

출처: Ruby 4.0 API - Data

클래스 메서드

  • define(*symbols) → class — 새 Data 클래스를 정의해요.

    measure = Data.define(:amount, :unit)
    #=> #<Class:0x00007f70c6868498>
    measure.new(1, 'km')
    #=> #<data amount=1, unit="km">
    
    # 새 클래스를 상수에 저장하면 #inspect에도 반영되고 더 자연스러워요:
    Measure = Data.define(:amount, :unit)
    #=> Measure
    Measure.new(1, 'km')
    #=> #<data Measure amount=1, unit="km">
    

    멤버가 없는 Data도 괜찮아요. 여러 동질의 데이터 클래스를 정의하는 유용한 기법이 될 수 있어요.

    class HTTPFetcher
      Response = Data.define(:body)
      NotFound = Data.define
      # ... implementation
    end
    

    이제 HTTPFetcher의 서로 다른 응답 종류들이 일관된 표현을 갖게 돼요. 그리고 패턴 매칭에도 쓰기 편리해요:

    case fetcher.get(url)
    in HTTPFetcher::Response(body)
      # process body variable
    in HTTPFetcher::NotFound
      # handle not found case
    end
    
  • DataClass::members → array_of_symbols — 데이터 클래스의 멤버 이름 배열을 돌려줘요: Measure.members # => [:amount, :unit]

  • new(*args) → instance, new(**kwargs) → instance, ::[](*args) → instance, ::[](**kwargs) → instance::define으로 정의된 클래스의 생성자들은 위치 인자와 키워드 인자를 모두 받아요. Struct와 달리 모든 인자가 필수이고, 키워드 인자로 변환돼요.

    Measure = Data.define(:amount, :unit)
    
    Measure.new(1, 'km')
    #=> #<data Measure amount=1, unit="km">
    Measure.new(amount: 1, unit: 'km')
    #=> #<data Measure amount=1, unit="km">
    
    # [] 를 쓴 더 짧은 초기화
    Measure[1, 'km']
    #=> #<data Measure amount=1, unit="km">
    Measure[amount: 1, unit: 'km']
    #=> #<data Measure amount=1, unit="km">
    

    인자가 빠지면 오류예요:

    Measure.new(amount: 1)
    # in `initialize': missing keyword: :unit (ArgumentError)
    
    Measure.new(1)
    # in `initialize': missing keyword: :unit (ArgumentError)
    

    Measure#initialize는 항상 키워드 인자를 받고, 필수 인자 검사는 new가 아니라 initialize에서 일어나요. 인자를 변환하거나 기본값을 주려고 initialize를 재정의할 때 이 점이 중요해요:

    Measure = Data.define(:amount, :unit)
    class Measure
      NONE = Data.define
    
      def initialize(amount:, unit: NONE.new)
        super(amount: Float(amount), unit:)
      end
    end
    
    Measure.new('10', 'km') # => #<data Measure amount=10.0, unit="km">
    Measure.new(10_000)     # => #<data Measure amount=10000.0, unit=#<data Measure::NONE>>
    

인스턴스 메서드

  • self == other → true or falseotherself와 같은 클래스이고 모든 멤버가 같으면 true를 돌려줘요.

    Measure = Data.define(:amount, :unit)
    
    Measure[1, 'km'] == Measure[1, 'km'] #=> true
    Measure[1, 'km'] == Measure[2, 'km'] #=> false
    Measure[1, 'km'] == Measure[1, 'm']  #=> false
    
    Measurement = Data.define(:amount, :unit)
    # Measurement와 Measure가 같은 "모양"이어도
    # 그 인스턴스들은 절대 같지 않아요
    Measure[1, 'km'] == Measurement[1, 'km'] #=> false
    
  • deconstruct → array — 패턴 매칭에 쓰이도록 self의 값들을 배열로 돌려줘요.

    Measure = Data.define(:amount, :unit)
    
    distance = Measure[10, 'km']
    distance.deconstruct #=> [10, "km"]
    
    # 사용 예시
    case distance
    in n, 'km' # 내부적으로 #deconstruct 호출
      puts "It is #{n} kilometers away"
    else
      puts "Don't know how to handle it"
    end
    # prints "It is 10 kilometers away"
    

    클래스 확인을 함께 쓰면 이렇게도 돼요:

    case distance
    in Measure(n, 'km')
      puts "It is #{n} kilometers away"
    # ...
    end
    
  • deconstruct_keys(array_of_names_or_nil) → hash — 패턴 매칭에 쓰이도록 이름/값 쌍의 해시를 돌려줘요.

    Measure = Data.define(:amount, :unit)
    
    distance = Measure[10, 'km']
    distance.deconstruct_keys(nil) #=> {:amount=>10, :unit=>"km"}
    distance.deconstruct_keys([:amount]) #=> {:amount=>10}
    
    # 사용 예시
    case distance
    in amount:, unit: 'km' # 내부적으로 #deconstruct_keys 호출
      puts "It is #{amount} kilometers away"
    else
      puts "Don't know how to handle it"
    end
    # prints "It is 10 kilometers away"
    
  • eql?(other) → true or false — 두 데이터 항목이 Hash의 키로 쓰일 때 사용되는 동등성 검사예요. ==와의 미묘한 차이는, 멤버도 각자의 eql? 메서드로 비교한다는 점이에요.

    Measure = Data.define(:amount, :unit)
    
    Measure[1, 'km'] == Measure[1.0, 'km'] #=> true, 값으로는 같아요
    # ...하지만...
    Measure[1, 'km'].eql? Measure[1.0, 'km'] #=> false, 다른 해시 키를 나타내요
    
  • hash → integerObject#hash를 재정의해서 (객체를 Hash 키로 구분할 때 쓰임) 같은 클래스의 같은 내용을 가진 데이터 객체가 같은 hash 값을 갖고 같은 Hash 키를 나타내도록 해요.

    Measure = Data.define(:amount, :unit)
    
    Measure[1, 'km'].hash == Measure[1, 'km'].hash #=> true
    Measure[1, 'km'].hash == Measure[10, 'km'].hash #=> false
    Measure[1, 'km'].hash == Measure[1, 'm'].hash #=> false
    Measure[1, 'km'].hash == Measure[1.0, 'km'].hash #=> false
    
    # 구조적으로 비슷해도 같은 해시 키로 보면 안 돼요
    Measurement = Data.define(:amount, :unit)
    
    Measure[1, 'km'].hash == Measurement[1, 'km'].hash #=> false
    
  • inspect → string (to_s의 별칭) — self의 문자열 표현을 돌려줘요.

    Measure = Data.define(:amount, :unit)
    
    distance = Measure[10, 'km']
    
    p distance  # 내부적으로 #inspect 사용
    #<data Measure amount=10, unit="km">
    
    puts distance  # 내부적으로 #to_s 사용, 같은 표현
    #<data Measure amount=10, unit="km">
    
  • members → array_of_symbolsself의 멤버 이름을 배열로 돌려줘요: distance.members #=> [:amount, :unit]

  • to_h → hash, to_h {|name, value| ... } → hash — 데이터 객체의 Hash 표현을 돌려줘요.

    Measure = Data.define(:amount, :unit)
    distance = Measure[10, 'km']
    
    distance.to_h
    #=> {:amount=>10, :unit=>"km"}
    

    Enumerable#to_h처럼 블록을 주면 키-값 쌍을 만들어 해시를 구성해 달라는 뜻이에요:

    distance.to_h { |name, val| [name.to_s, val.to_s] }
    #=> {"amount"=>"10", "unit"=>"km"}
    

    to_h와 초기화 사이에는 유용한 대칭이 있어요:

    distance2 = Measure.new(**distance.to_h)
    #=> #<data Measure amount=10, unit="km">
    distance2 == distance
    #=> true
    
  • with(**kwargs) → instanceself의 얕은 복사본을 돌려줘요. 키워드 인자를 주면 해당 필드 값이 그 키워드 값으로 갱신된 복사본을 만들어요. Data 클래스가 멤버로 갖지 않는 키워드를 주면 오류예요.

    Point = Data.define(:x, :y)
    
    origin = Point.new(x: 0, y: 0)
    
    up = origin.with(x: 1)
    right = origin.with(y: 1)
    up_and_right = up.with(y: 1)
    
    p origin       # #<data Point x=0, y=0>
    p up           # #<data Point x=1, y=0>
    p right        # #<data Point x=0, y=1>
    p up_and_right # #<data Point x=1, y=1>
    
    out = origin.with(z: 1) # ArgumentError: unknown keyword: :z
    some_point = origin.with(1, 2) # ArgumentError: expected keyword arguments, got positional arguments
    

더 알아보기

  • Struct — 비슷하지만 더 컨테이너스러운 API를 제공하는 개념