타입 추론

타입 추론 (Type inference)

Crystal의 철학은 가능한 한 적은 타입 제한을 요구하는 것이에요. 다만 어떤 제한은 반드시 필요해요.

다음과 같은 클래스 정의를 생각해 볼게요:

class Person
  def initialize(@name)
    @age = 0
  end
end

@age가 정수라는 건 금방 알 수 있지만, @name의 타입은 알 수 없어요. 컴파일러가 Person 클래스의 모든 사용처에서 @name의 타입을 추론할 수도 있었겠죠. 다만 그렇게 하면 몇 가지 문제가 있어요:

  • 코드를 읽는 사람 입장에서 타입이 명확하지 않아요. 그 타입을 알아내려면 Person의 모든 사용처를 일일이 확인해야 하니까요.
  • 메서드를 단 한 번만 분석하면 되는 최적화나, 점진적 컴파일(incremental compilation) 같은 컴파일러 최적화가 거의 불가능해져요.

코드베이스가 커지면 이런 문제들은 더 중요해져요. 프로젝트를 이해하기 어려워지고, 컴파일 시간도 감당할 수 없을 만큼 길어지거든요.

그래서 Crystal은 인스턴스 변수와 클래스 변수의 타입을 (사람 눈에도 분명할 만큼) 명확하게 알아야 해요.

Crystal에 이걸 알려주는 방법은 여러 가지가 있어요.

출처: Crystal 공식 문서

본문

타입 제한과 함께 (With type restrictions)

가장 쉽지만 아마 가장 번거로운 방법은 명시적 타입 제한을 쓰는 거예요.

class Person
  @name : String
  @age : Int32

  def initialize(@name)
    @age = 0
  end
end

타입 제한 없이 (Without type restrictions)

명시적 타입 제한을 생략하면, 컴파일러는 여러 문법 규칙을 사용해 인스턴스 변수와 클래스 변수의 타입을 추론하려고 해요.

주어진 인스턴스/클래스 변수에 대해, 규칙을 적용할 수 있고 타입을 추측할 수 있으면 그 타입이 집합에 추가돼요. 더 이상 적용할 규칙이 없으면, 추론된 타입은 그 타입들의 유니온이 돼요. 게다가 컴파일러가 인스턴스 변수가 항상 초기화되지 않는다고 추론하면 Nil 타입도 포함해요.

규칙은 많지만, 보통 처음 세 개가 가장 많이 쓰여요. 전부 기억할 필요는 없어요. 컴파일러가 인스턴스 변수의 타입을 추론할 수 없다는 에러를 주면 언제든 명시적 타입 제한을 추가하면 되니까요.

아래 규칙들은 인스턴스 변수만 언급하지만 클래스 변수에도 동일하게 적용돼요. 규칙들은 다음과 같아요:

1. 리터럴 값 할당하기 (Assigning a literal value)

리터럴이 인스턴스 변수에 할당되면, 그 리터럴의 타입이 집합에 추가돼요. 모든 리터럴은 연관된 타입을 가져요.

다음 예시에서 @nameString으로, @ageInt32로 추론돼요.

class Person
  def initialize
    @name = "John Doe"
    @age = 0
  end
end

이 규칙은, 그리고 이 뒤의 모든 규칙도 initialize 외의 메서드에서도 적용돼요. 예를 들어:

class SomeObject
  def lucky_number
    @lucky_number = 42
  end
end

위 경우 @lucky_numberInt32 | Nil로 추론될 거예요. Int32는 42가 할당됐기 때문이고, Nil은 클래스의 모든 initialize 메서드에서 할당되지 않았기 때문이에요.

2. 클래스 메서드 new를 호출한 결과 할당하기 (Assigning the result of invoking the class method new)

Type.new(...) 같은 표현식이 인스턴스 변수에 할당되면, Type이 집합에 추가돼요.

다음 예시에서 @addressAddress로 추론돼요.

class Person
  def initialize
    @address = Address.new("somewhere")
  end
end

이것은 제네릭 타입에도 적용돼요. 여기 @valuesArray(Int32)로 추론돼요.

class Something
  def initialize
    @values = Array(Int32).new
  end
end

참고: new 메서드는 한 타입에 의해 재정의될 수도 있어요. 그런 경우 추론된 타입은 new가 반환하는 것, 다음 규칙들 중 일부로 추론할 수 있다면 그 타입이 돼요.

3. 타입 제한이 있는 메서드 파라미터인 변수 할당하기 (Assigning a variable that is a method parameter with a type restriction)

다음 예시에서 @nameString으로 추론돼요. 메서드 파라미터 nameString 타입 제한을 갖고 있고, 그 파라미터가 @name에 할당되기 때문이에요.

class Person
  def initialize(name : String)
    @name = name
  end
end

메서드 파라미터의 이름은 중요하지 않아요. 이것도 동작하니까요:

class Person
  def initialize(obj : String)
    @name = obj
  end
end

메서드 파라미터에서 인스턴스 변수로 할당하는 짧은 문법을 써도 같은 효과가 있어요:

class Person
  def initialize(@name : String)
  end
end

또한 컴파일러는 메서드 파라미터가 다른 값으로 재할당되는지 확인하지 않는다는 점도 주의하세요:

class Person
  def initialize(name : String)
    name = 1
    @name = name
  end
end

위 경우 컴파일러는 여전히 @nameString으로 추론할 거예요. 그리고 나중에 그 메서드를 완전히 타이핑할 때, Int32String 타입의 변수에 할당될 수 없다는 컴파일 타임 에러를 줄 거예요. @nameString이 아니어야 한다면 명시적 타입 제한을 사용하세요.

4. 반환 타입 제한이 있는 클래스 메서드의 결과 할당하기 (Assigning the result of a class method that has a return type restriction)

다음 예시에서 @addressAddress로 추론돼요. 클래스 메서드 Address.unknownAddress 반환 타입 제한을 갖고 있기 때문이에요.

class Person
  def initialize
    @address = Address.unknown
  end
end

class Address
  def self.unknown : Address
    new("unknown")
  end

  def initialize(@name : String)
  end
end

사실 위 코드에는 self.unknown에 반환 타입 제한이 필요 없어요. 컴파일러가 클래스 메서드의 본문도 살펴보고, 이전 규칙 중 하나(그것이 new 메서드이거나 리터럴이거나 등)를 적용할 수 있으면 그 표현식에서 타입을 추론하기 때문이에요. 그래서 위 코드는 이렇게 간단히 쓸 수 있어요:

class Person
  def initialize
    @address = Address.unknown
  end
end

class Address
  # No need for a return type restriction here
  def self.unknown
    new("unknown")
  end

  def initialize(@name : String)
  end
end

이 추가 규칙은 매우 편리한데, new 외에 "생성자 같은(constructor-like)" 클래스 메서드를 두는 게 아주 흔하기 때문이에요.

5. 기본값이 있는 메서드 파라미터인 변수 할당하기 (Assigning a variable that is a method parameter with a default value)

다음 예시에서 name의 기본값이 문자열 리터럴이고, 나중에 @name에 할당되기 때문에 String이 추론된 타입 집합에 추가돼요.

class Person
  def initialize(name = "John Doe")
    @name = name
  end
end

물론 짧은 문법으로도 동작해요:

class Person
  def initialize(@name = "John Doe")
  end
end

기본 파라미터 값은 Type.new(...) 메서드나 반환 타입 제한이 있는 클래스 메서드일 수도 있어요.

6. lib 함수를 호출한 결과 할당하기 (Assigning the result of invoking a lib function)

lib 함수는 명시적 타입을 가져야 하므로, 컴파일러는 인스턴스 변수에 할당할 때 그 반환 타입을 사용할 수 있어요.

다음 예시에서 @ageInt32로 추론돼요.

class Person
  def initialize
    @age = LibPerson.compute_default_age
  end
end

lib LibPerson
  fun compute_default_age : Int32
end

7. out lib 표현식 사용하기 (Using an out lib expression)

lib 함수는 명시적 타입을 가져야 하므로, 컴파일러는 out 인자의 타입(포인터 타입이어야 해요)과, 역참조된 타입을 추측으로 사용할 수 있어요.

다음 예시에서 @ageInt32로 추론돼요.

class Person
  def initialize
    LibPerson.compute_default_age(out @age)
  end
end

lib LibPerson
  fun compute_default_age(age_ptr : Int32*)
end

기타 규칙 (Other rules)

컴파일러는 명시적 타입 제한을 덜 요구하기 위해 최대한 똑똑해지려고 해요. 예를 들어 if 표현식을 할당하면, thenelse 분기에서 타입이 추론돼요:

class Person
  def initialize
    @age = some_condition ? 1 : 2
  end
end

if(엄밀히는 삼항 연산자지만 if와 비슷해요)에 정수 리터럴이 있으므로, 불필요한 타입 제한 없이도 @ageInt32로 성공적으로 추론돼요.

또 다른 경우는 ||||=예요:

class SomeObject
  def lucky_number
    @lucky_number ||= 42
  end
end

위 예시에서 @lucky_numberInt32 | Nil로 추론될 거예요. 지연 초기화(lazy initialization) 변수에 아주 유용해요.

상수도 따라가져요. 컴파일러(그리고 사람)에게 꽤 단순한 일이기 때문이에요.

class SomeObject
  DEFAULT_LUCKY_NUMBER = 42

  def initialize(@lucky_number = DEFAULT_LUCKY_NUMBER)
  end
end

여기서는 규칙 5(기본 파라미터 값)가 사용되고, 상수가 정수 리터럴로 해석되므로 @lucky_numberInt32로 추론돼요.

더 알아보기

  • Crystal은 최소한의 타입 제한을 지향하지만, 인스턴스·클래스 변수의 타입은 명확하게 알아야 해요.
  • 리터럴, new 호출, 타입 제한·기본값 있는 파라미터, 반환 타입 제한, lib 함수 등이 추론에 쓰여요.
  • ||=나 지연 초기화 변수처럼 항상 초기화되지 않는 변수는 Nil이 유니온에 포함돼요.
  • 추론이 어려운 경우 명시적 타입 제한을 추가하면 돼요.