rokdocs
v0.1

2.2. any 슬롯

any 는 어떤 타입의 값이든 담을 수 있는 변수 슬롯을 가리키는 타입 이름이며 (타입 자리에서만 키워드인 문맥 키워드, 1.5.4; any 라는 이름의 타입 선언은 에러), rok 점진 타이핑 (gradual typing) 의 핵심을 이루는 개념입니다.

중요

any 슬롯은 any 라고 적은 곳에만 생깁니다. 타입 명시를 생략한 변수는 초기값의 타입으로 추론된 정적 슬롯이 됩니다 (5.1.7). rok 점진 타이핑은 "정적이 기본, 동적은 any 라고 적힌 곳에만" 이라는 한 줄 규칙으로 결정됩니다.

2.2.1. 정적 슬롯과의 대비

먼저 정적 슬롯과 any 슬롯이 어떻게 구분되는지 살펴보겠습니다.

let x: i32 = 42i32;     // x 는 i32 - 다른 타입의 값을 담을 수 없다
let y: any = 42i32;     // y 는 any 슬롯 - 어떤 타입이든 가능
let z = 42i32;          // z 는 i32 - 초기값에서 추론한 정적 슬롯

위 세 줄에서 x 와 z 는 같은 의미를 가지며, any 슬롯은 y 하나뿐입니다. 타입 명시를 생략하면 컴파일러가 초기값의 타입을 슬롯 타입으로 정하기 때문입니다. 이러한 결정 덕분에 REPL 과 노트북에서는 타입을 적지 않고 빠르게 입력하면서도 정적 디스패치를 얻을 수 있고, 동적 디스패치 비용이 생기는 곳은 선언 문법에서 바로 드러납니다.

2.2.2. 슬롯의 메모리 표현

이어서 any 슬롯의 값이 어떻게 표현되는지 알아보겠습니다. any 슬롯의 값은 태그와 페이로드를 묶은 형태로 표현됩니다.

+------+----------+
| tag  | payload  |
+------+----------+

여기서 태그는 페이로드가 어떤 타입을 가리키는지를 나타내는 식별자이고, 페이로드는 값 자체로서 작은 값 (원시 타입 등) 은 직접 저장하며 큰 값은 힙 객체에 대한 참조로 저장합니다.

태그·페이로드의 정확한 비트 폭과 정렬, 인코딩은 8. 런타임 ABI 에서 정의하며, 본 문서는 모양 (태그 + 페이로드 두 부분) 만 약속합니다.

2.2.3. 동적 디스패치

any 슬롯의 값에 연산을 가하면, 런타임이 태그를 검사해 적절한 트레잇 메서드를 호출합니다.

let v: any = 42i32;
let w = v + 1i32;           // 런타임에 v 의 태그를 보고 i32::add 호출

다만 이 디스패치 비용은 정적 호출보다 명백히 크기 때문에, 성능이 중요한 곳에서는 정적 슬롯을 사용해야 합니다.

any 위의 연산자

any 피연산자에 연산자를 쓰면 규칙은 다음과 같습니다.

  • 후보는 그 연산자를 가진 원시 타입들이며, 두 피연산자는 같은 타입이어야 합니다.
  • 한쪽 피연산자가 정적 타입이면 그 타입으로 후보가 하나로 정해집니다. 정적 피연산자가 원시 타입이 아니면 컴파일 에러입니다.
  • 런타임에 두 피연산자의 타입이 맞지 않으면 panic 합니다 (종료 코드 101).
  • 모든 후보의 결과가 bool 이면 결과는 bool 이고, 그렇지 않으면 boxing 된 any 입니다.
let v: any = 42i32;
let w = v + 1i32;           // w: any - i32 + i32
let b = v == 1i32;          // b: bool
let p = v + Point {};       // ERROR - Point 는 원시 타입이 아님

2.2.4. 재할당. 타입이 바뀌어도 OK

any 슬롯은 다른 타입의 값으로 자유롭게 재할당할 수 있습니다.

let s: any = 42i32;
s = "hello";        // OK - 슬롯이 any 라서
s = 3.14f64;        // OK

반면에 정적 슬롯은 같은 타입의 재할당만 허용됩니다.

let s: i32 = 42i32;
s = 10i32;          // OK
s = "hello";        // ERROR

2.2.5. 슬롯의 현재 타입 검사

any 슬롯에 담긴 값의 현재 타입은 후위 연산자 is 로 검사할 수 있습니다. e is T 의 결과는 bool 이며, 우선순위는 as 와 같습니다 (3.2.3).

if v is i32 { ... }

이 검사는 런타임 태그 비교 한 번으로 끝납니다. any 에 대한 is (A | B) 와 패턴 x: A | B 는 저장된 값이 A 나 B 일 때와, 합 A | B 값 전체가 저장되어 있을 때 모두 맞습니다. 바인딩은 합 A | B 로 다시 tag 됩니다. 피연산자의 정적 타입이 any 도 합 타입도 아니면 타입 검사 e is T 는 컴파일 타임 상수입니다. 대상이 합이면 소속을 검사합니다: 보통 타입 A 의 값에 대해 e is (A | B) 는 true 입니다.

정적 타입이 enum 인 값에는 e is Enum::Variant 로 변형을 검사할 수 있습니다. 런타임 변형 비교이며, 그 변형이면 payload 와 무관하게 true 입니다. 좁히기는 하지 않습니다. payload 가 필요하면 match 나 if let 을 씁니다.

if shape is Shape::Circle { count += 1; }        // Circle(_) 인지만 검사

2.2.6. 좁히기 (narrowing)

is 검사 또는 패턴 매칭이 성공한 분기 안에서, 컴파일러는 슬롯 타입을 해당 검사 타입으로 좁힌 것으로 간주합니다. 따라서 그 분기 안에서는 정적 디스패치가 가능해집니다.

let v: any = ...;
if v is i32 {
    let w = v + 1i32;       // 이 분기 안에서 v 는 i32 - 정적 디스패치
}

좁히기 규칙은 다음과 같습니다.

  • 좁히기 조건은 x is T 이며, x 는 지역 변수여야 합니다. 조건 전체이거나 a && b && .. 체인의 한 항일 수 있습니다.
  • x 는 그 뒤의 && 항, if 의 then 블록, while 본체에서 T 입니다.
  • || 나 ! 아래, else 블록, 필드 경로 (p.x is T) 에서는 좁히지 않습니다.
  • 변형 검사 x is Enum::Variant (2.2.5) 는 좁히지 않습니다.
  • 피연산자의 정적 타입이 검사하는 합의 보통 멤버 A 이면 x is (A | B) 는 좁히지 않으며, x 는 타입 A 를 유지합니다.
  • x 가 [ref x] (4.6.2) 로 캡처되면, 좁힌 범위에서 x 를 읽을 때마다 공유 cell 을 다시 검사합니다. 클로저가 T 가 아닌 값을 저장했다면 그 읽기는 panic 합니다.
  • 좁힌 범위 안의 x 는 타입 T 입니다. x (또는 그 필드 / 원소) 에 대입하면 원래 슬롯에 다시 변환되어 기록됩니다. 선언 타입의 값이지만 T 가 아닌 값을 대입하면 그 범위의 나머지에서 좁히기가 끝납니다: x 는 다시 선언 타입이 되고 일반 흐름 타이핑이 이어집니다. 선언 타입 밖의 값을 대입하는 것은 여전히 타입 에러입니다.
let v: any = 1i32;
if v is i32 && v > 0i32 {      // 두 번째 항에서 v: i32
    v = v + 1i32;              // 원래 any 슬롯에 기록
    v = "x";                   // 좁히기가 끝남: v 는 다시 any
}
if v is i32 || flag { ... }    // 좁히지 않음

패턴으로 좁히는 방법은 패턴 의 타입 검사 패턴을 참조합니다.

2.2.7. 점진의 경계

any 슬롯은 any 를 명시한 자리에만 생깁니다. 타입 명시를 생략했을 때의 의미는 위치마다 다음과 같이 정해집니다.

변수에 타입을 적지 않으면

let n = 42i32;          // n: i32
let s = "hi";           // s: 리터럴 핸들러가 정한 문자열 타입
let n2: any = 42i32;    // 동적 슬롯은 명시할 때만

let 은 지역 타입 추론을 따릅니다. 초기값의 타입이 슬롯의 타입이 되며, 이후 재할당은 그 타입으로 검사합니다. 초기값에서 타입을 정할 수 없는 경우의 규칙은 5.1.7 에서 정의합니다.

함수 매개변수에 타입을 적지 않으면

fn compute(x, y) { ... }

위 코드는 제네릭 함수에 해당합니다. 변수와 마찬가지로 "타입 생략 = 컴파일러가 정적으로 정한다" 는 규칙을 따릅니다. 호출 지점의 타입에 따라 단형화되는데, 호출자가 i32 두 개를 넘기면 그 호출은 i32 두 개를 받는 함수로 컴파일되고, 다른 호출자가 f64 두 개를 넘기면 또 다른 함수가 컴파일됩니다.

만약 매개변수를 동적 슬롯으로 만들고 싶다면 명시적으로 any 를 적어 주어야 합니다.

fn compute(x: any, y: any) { ... }    // 동적 슬롯

구조체 필드

구조체 필드의 경우에는 반드시 타입을 명시해야 합니다.

struct Point { x: f64; y: f64; }      // OK
struct Mixed { name; value; }          // ERROR - 필드 타입 누락

필드를 동적으로 만들려면 마찬가지로 명시적 any 를 사용합니다.

struct Cell { tag: i32; payload: any; }   // OK

이러한 강제 규칙은 컴파일러가 구조체 메모리 레이아웃을 결정할 수 있도록 하기 위한 것입니다.

2.2.8. 슬롯 종류는 선언에서 정해집니다

변수의 슬롯이 동적인지 정적인지는 선언 문법만 보고 알 수 있습니다. : any 를 적으면 동적 슬롯이고, 그 밖의 경우 (타입 명시 또는 추론) 는 정적 슬롯입니다. 같은 스코프 안에서 같은 이름을 다시 선언하는 것은 허용하지 않으므로, 한 번 정해진 슬롯 종류가 스코프 안에서 바뀌는 일은 없습니다.

let x = 1;              // 정적 슬롯 (초기값에서 추론)
let x: any = 5i32;      // ERROR - 같은 스코프에 x 가 이미 있음

물론 새 스코프에서는 별개 바인딩으로 동작하며, 이에 대한 자세한 내용은 변수 선언 에서 정의합니다.

2.2.9. 컴파일러 / 도구 친화성

마지막으로 any 슬롯이 있는 코드는 형 검사가 약해진다는 점을 알아 두어야 합니다. 컴파일러는 그 슬롯의 실제 타입을 미리 알 수 없기 때문에, 도구 (LSP·정적 분석) 도 이 한계 안에서 최선을 다할 수밖에 없습니다. 결국 점진 vs 정적의 선택은 any 를 적느냐로 드러나는 사용자의 명시적 결정이며, 사양은 두 모드를 모두 정식으로 지원합니다.

2.2.10. 제약 붙은 any: any Trait

any 뒤에 트레잇을 적으면, 그 트레잇을 구현하는 타입의 값만 담는 동적 슬롯이 됩니다.

let e: any Error = ParseError { input: s, pos: 0, reason: "empty" };
let shapes: List<any Shape> = [Circle { r: 1.0 }, Square { side: 2.0 }];
let item: any (Display + Hash) = 42i32;

형태는 맨 any, 트레잇 하나를 붙인 any Trait, 여러 트레잇을 괄호로 묶은 any (A + B) 의 세 가지입니다. 맨 any 는 bound 가 없는 any () 와 같습니다 (문법은 타입 표현식 의 any_type). any 는 뒤의 트레잇 하나만 묶으므로 any Error | none 은 (any Error) | none 입니다.

맨 트레잇 이름을 타입 자리에 쓰는 것은 여전히 금지됩니다 (트레잇-as-타입 금지). any Trait 은 동적 디스패치 비용이 any 키워드로 드러나는 형태이기 때문에 허용됩니다.

저장 시 검사

any Tr 슬롯에 값을 넣을 때, 컴파일러는 그 값의 정적 타입이 Tr 을 구현하는지 컴파일 타임에 검사합니다. any (A + B) 는 A 와 B 를 모두 구현해야 합니다.

let a: any Display = 42i32;          // OK - i32: Display
let b: any Display = Opaque {};      // ERROR - Opaque 는 Display 를 구현하지 않음

let v: any = 42i32;
let c: any Display = v;              // ERROR - any → any Display 암묵 변환 없음
if v is i32 {
    let d: any Display = v;          // OK - 좁힌 뒤에는 정적 타입 i32
}

any 타입 사이의 변환은 넓히는 방향만 허용합니다.

  • any A → any B 는 B 의 모든 bound 가 A 의 어떤 bound 로 덮일 때 허용됩니다. 덮는다는 것은 같은 트레잇이거나, 슈퍼트레잇을 (여러 단계로) 따라가 닿는다는 뜻입니다. Tr<args> 는 Tr 을 덮습니다.
  • any X → 맨 any 는 항상 허용됩니다.
  • 그 밖의 방향 (any → any Tr 등 좁히는 변환) 은 에러입니다. 런타임 검사를 숨기는 암묵 변환을 두지 않기 위함이며, is 검사나 타입 패턴으로 구체 타입을 꺼낸 뒤에 넣어야 합니다.
let e: any Error = ...;
let d: any Display = e;              // OK - Display 는 Error 의 슈퍼트레잇
let x: any = e;                      // OK
let e2: any Error = x;               // ERROR - 좁히는 변환

사용

any Tr 값에서는 Tr 과 그 슈퍼트레잇의 메서드만 호출할 수 있으며, 호출은 런타임에 태그로 디스패치됩니다. 단, self 를 제외한 시그니처에 다음이 나오는 메서드는 호출할 수 없습니다.

  • Self
  • bound 의 인자로 고정되지 않은 트레잇 타입 매개변수
  • bound 의 X = .. 바인딩으로 고정되지 않은 associated type

bound 는 인자와 바인딩을 생략할 수 있으며, 생략한 것은 제약이 없는 것으로 봅니다. any Tr<A, X = B> 위의 호출은 트레잇 선언의 시그니처에 A 와 B 를 대입한 시그니처로 검사합니다. 따라서 Tr 을 제네릭 impl 만 구현하는 경우에도 호출할 수 있습니다. 예를 들어 any Iterator 에서는 next 를 호출할 수 없고 (Item 미고정), any Iterator<Item = i32> 에서는 호출할 수 있습니다. 비용 모델은 2.2.3 의 any 와 같습니다. 그 밖의 메서드나 필드를 쓰려면 2.2.5-2.2.6 과 같이 is 검사나 타입 패턴으로 구체 타입으로 좁힙니다.

fn describe(e: any Error) -> str {
    let msg = f"{e}";                       // Display - Error 의 슈퍼트레잇
    match e {
        p: ParseError => f"{msg} at {p.pos}",   // 구체 타입으로 좁힘
        _ => msg,
    }
}

표현

any Tr 값의 표현은 any 와 같은 태그 + 페이로드입니다 (2.2.2). 트레잇 메서드의 디스패치 표는 링크 시점에, 프로그램 안에서 any 슬롯에 저장되는 모든 타입으로 만듭니다 (closed world, 단일 패키지 컴파일 기준). 제네릭 impl 이나 blanket impl 로 트레잇을 구현하는 타입도 포함됩니다. 따라서 값마다 별도의 vtable 포인터를 들고 다니지 않습니다.

GitHub에서 이 페이지 고치기