Structs

Structs (구조체)

Crystal에서 타입을 정의하는 키워드는 class만 있는 게 아니에요. struct로도 타입을 만들 수 있는데, 이 둘은 값이 저장되고 전달되는 방식에서 큰 차이가 있어요. 간단한 값 타입을 만들 때 struct가 아주 유용해요.

출처: Crystal 공식 문서 - Structs

본문

class 대신 struct로 타입을 정의할 수 있어요.

struct Point
  property x, y

  def initialize(@x : Int32, @y : Int32)
  end
end

structValue를 상속하므로 스택에 할당되고 값으로 전달돼요. 즉 메서드에 넘기거나 메서드에서 반환하거나 변수에 할당할 때 실제로 값의 복사본이 전달돼요. 반면 classReference를 상속해서 힙에 할당되고 참조로 전달돼요.

따라서 struct는 주로 불변(immutable) 데이터 타입이나 다른 타입의 상태 없는 래퍼를 만드는 데 유용해요. 특히 작은 복사본을 전달하는 게 더 효율적일 때, 메모리 할당을 많이 피하려는 성능상 이유가 큰 경우가 많아요. 자세한 내용은 성능 가이드를 참고하세요.

변경 가능한(mutable) struct도 물론 허용돼요. 다만 아래에서 설명할 놀라움을 피하려면 가변성(mutability)을 다루는 코드를 작성할 때 조심해야 해요.

값으로 전달하기

struct항상 값으로 전달돼요. 심지어 그 구조체의 메서드에서 self를 반환할 때조차도요.

struct Counter
  def initialize(@count : Int32)
  end

  def plus
    @count += 1
    self
  end
end

counter = Counter.new(0)
counter.plus.plus # => Counter(@count=2)
puts counter      # => Counter(@count=1)

counter.plus.plus가 기대한 결과(@count=2)를 반환하지만, 원래 변수 counter를 수정한 것은 첫 번째 호출뿐이에요. 두 번째 호출은 첫 번째 호출이 전달해 준 복사본에 대해 동작하고, 이 복사본은 표현식이 실행된 뒤 버려지기 때문이에요.

구조체 안에서 변경 가능한 타입을 다룰 때도 조심해야 해요.

class Klass
  property array = ["str"]
end

struct Strukt
  property array = ["str"]
end

def modify(object)
  object.array << "foo"
  object.array = ["new"]
  object.array << "bar"
end

klass = Klass.new
puts modify(klass) # => ["new", "bar"]
puts klass.array   # => ["new", "bar"]

strukt = Strukt.new
puts modify(strukt) # => ["new", "bar"]
puts strukt.array   # => ["str", "foo"]

여기서 strukt에 어떤 일이 벌어지는지 정리해 볼게요.

  • Array는 참조로 전달되므로 ["str"]에 대한 참조가 strukt의 프로퍼티에 저장돼요.
  • struktmodify에 넘기면, 안에 그 배열 참조를 가진 복사본이 전달돼요.
  • object.array << "foo"array가 참조하는 배열을 수정해요(요소 추가).
  • 이 배열은 원래 strukt와 같은 배열을 참조하므로 원본 strukt에도 그대로 반영돼요.
  • object.array = ["new"]strukt복사본에 있는 참조를 새 배열로 교체해요.
  • object.array << "bar"가 이 새 배열에 값을 추가해요.
  • modify는 이 새 배열의 참조를 반환하고 그 내용이 출력돼요.
  • 이 새 배열의 참조는 strukt복사본에만 들고 있었지 원본에는 없으므로, 원본 strukt는 첫 번째 문장의 결과만 남고 나머지 두 문장의 결과는 남지 않아요.

Klass는 클래스라서 modify참조로 전달돼요. 그래서 object.array = ["new"]가 새 배열의 참조를 복사본이 아니라 원래 klass 객체에 저장해요. strukt 때와 달리 원본에 그대로 반영되는 거죠.

상속

  • struct는 암묵적으로 Struct를 상속받고, StructValue를 상속받아요. 클래스는 암묵적으로 Reference를 상속받아요.
  • struct비추상(non-abstract) 구조체를 상속할 수 없어요.

두 번째 규칙에는 이유가 있어요. 구조체는 메모리 레이아웃이 아주 잘 정의되어 있어요. 예를 들어 위의 Point 구조체는 8바이트를 차지해요. 포인트 배열이 있으면 각 포인트는 배열 버퍼 안에 그대로 박혀 있어요.

# 배열의 버퍼는 각 Point마다 8바이트를 할당해요
ary = [] of Point

만약 Point가 상속될 수 있다면, 배열 안에 다른 타입이 들어올 가능성까지 고려해야 하므로 각 요소의 크기를 키워야 해요. 그것은 분명히 예상 밖의 일이에요. 그래서 비추상 구조체는 상속할 수 없어요. 반면 추상(abstract) 구조체는 하위 타입을 가질 것이므로, 그 배열이 여러 타입을 담을 가능성을 고려하는 게 당연해요.

구조체는 클래스처럼 모듈을 include할 수도 있고 제네릭일 수도 있어요.

레코드

Crystal 표준 라이브러리record 매크로를 제공해요. 이 매크로는 초기화 메서드와 몇 가지 헬퍼 메서드를 가진 기본 구조체 타입의 정의를 간단히 만들어 줘요.

record Point, x : Int32, y : Int32

Point.new 1, 2 # => #<Point(@x=1, @y=2)>

record 매크로는 다음 구조체 정의로 확장돼요.

struct Point
  getter x : Int32

  getter y : Int32

  def initialize(@x : Int32, @y : Int32)
  end

  def copy_with(x _x = @x, y _y = @y)
    self.class.new(_x, _y)
  end

  def clone
    self.class.new(@x.clone, @y.clone)
  end
end

더 알아보기