Carbon 언어에서 값과 객체, 식 카테고리, let/var 바인딩 패턴과 지역 변수, 값/참조/초기화 식 간 변환, 함수 호출과 반환, 포인터와 const 한정, 수명 관련 고려, 값 표현 커스터마이징 등을 설명한다.
Carbon에는 추상적인 값(value)과 구체적인 객체(object)가 모두 있다. Carbon의 값은 42, true, i32(타입 값) 같은 것들이다. Carbon의 객체는 값을 읽고 쓸 수 있는 저장 공간(storage)을 가진다. 저장 공간이 있기 때문에 Carbon에서는 객체의 메모리 주소를 취할 수도 있다.
객체와 값은 서로를 포함해 중첩될 수 있다. 예를 들어 (true, true)는 값이면서 두 개의 하위 값을 포함한다. 2-튜플이 어딘가에 저장되면, 그것은 튜플 타입의 객체이면서 두 개의 하위 객체를 포함한다.
이 용어들은 Carbon 코드의 의미론을 설명하는 데 중요한 구성 요소지만, 이것만으로는 충분하지 않다. 값을 생성하거나 객체를 참조하는 Carbon의 식(expression)에 대해서도 명시적이고 정밀하게 이야기해야 한다. 식 자체를 분류하면, 식을 들여다보지 않으면 포착되지 않는 중요한 차이를 더 정밀하게 표현할 수 있다.
한 분류의 식은 필요할 때 다른 어떤 분류로도 변환될 수 있다. 사용되는 기본 변환 단계는 다음과 같다:
이러한 변환 단계는 결합되어 다음의 추이적 변환 표를 제공한다:
| 원본: | 값 | 참조 | 초기화 |
|---|---|---|---|
| 대상: 값 | == | 값 획득 | 구체화 + 값 획득 |
| 대상: 참조 | 직접 초기화 + 구체화 | == | 구체화 |
| 대상: 초기화 | 직접 초기화 | 복사 초기화 | == |
임시 구체화를 통해 형성된 참조 식을 일시적 참조 식이라고 하며, 사용에 제한이 있다. 이에 반해, 선언된 저장 공간을 참조하는 참조 식을 지속적 참조 식이라고 한다. 무엇이 유효한지에 대한 제한을 제외하면, 이 둘의 동작이나 의미론에는 차이가 없다.
참조 식으로부터 값 식을 형성하는 것을 값 획득(value acquisition)이라고 한다. 이는 참조 식이 가리키는 저장 공간의 객체 값을 평가 시에 산출하는 값 식을 형성한다. 이 값은 해당 값을 즉시 머신 레지스터에 읽거나, 필요 시 지연하여 레지스터에 읽는 등, 그 추상적 값을 모델링하는 어떠한 방식으로도 수행될 수 있다.
자세한 의미론은 값 식 섹션을 참고하라.
이는 객체의 저장 공간을 초기화하는 첫 번째 방법이다. 초기화의 근원에는 저장 공간이 없을 수도 있는데, 초기화에 사용되는 값 식이 머신 레지스터에 있거나 소스 리터럴처럼 순수하게 추상적으로 모델링될 수도 있기 때문이다. 대표적인 예는 객체를 0으로 채우는 경우다.
이미 초기화된 저장 공간을 가진 다른 어떤 객체를 바탕으로 객체의 저장 공간을 초기화한다. 전형적인 예는 하위 바이트를 memcpy로 복사해도 되는 사소 복사가 가능한 타입들이다.
저장 공간을 통해 객체를 초기화할 필요는 있지만, 전용 저장 공간이 제공되지 않았고, 이후 결과를 단순히 값으로 획득하면 되는 경우에 임시 구체화를 사용한다.
미해결 질문: 임시 객체의 수명은 아직 명세되지 않았다.
값 바인딩 패턴은 값 식인 이름을 도입하며, 이를 값 바인딩이라고 한다. 이는 많은 패턴 문맥, 특히 함수 매개변수에서 원하는 기본값이다. 값은 함수의 "입력" 매개변수를 모델링하는 데 적합하며, 이는 함수 매개변수의 지배적이고 기본적인 스타일이다:
fn Sum(x: i32, y: i32) -> i32 {
// 여기서 x와 y는 값 식이다. 값을 사용할 수는 있지만,
// 수정하거나 주소를 취할 수는 없다.
return x + y;
}
값 바인딩은 대응되는 식이 필요 시 변환을 통해 값 식이 되도록 요구한다.
변수 패턴은 var 키워드로 도입된다. 이는 새 객체의 저장 공간을 선언하고, 대응되는 식으로부터 이를 초기화한다. 이때 식은 초기화 식이어야 한다.
참조 바인딩 패턴은 var 패턴 아래에 중첩되는 바인딩 패턴이다. 이는 지속적 참조 식인 이름을 도입하며, 변수 패턴의 저장 공간 내의 객체를 참조한다.
fn MutateThing(ptr: i64*);
fn Example() {
// 1은 값 바인딩이 기대하는 대로 값 식으로 시작한다.
let x: i64 = 1;
// 2 역시 값 식으로 시작하지만, 변수 패턴은 이를 제공된 변수 저장 공간을
// 초기화하는 초기화 식으로 변환할 것을 요구한다. 그 결과 y가 해당 저장 공간을 참조한다.
var y: i64 = 2;
// y는 지속적 참조 식이므로 주소를 취하고 변경할 수 있다.
MutateThing(&y);
// ❌ 그러나 값 식 x의 주소를 취하려 하면 오류가 된다.
MutateThing(&x);
}
지역 바인딩 패턴은 let 또는 var 키워드로 도입할 수 있다. let 도입자는 값 패턴을 시작하며 다른 문맥의 기본 패턴과 동일하게 동작한다. var 도입자는 즉시 변수 패턴을 시작한다.
let 식별자 : (식 | auto) = 값 ;var 식별자 : (식 | auto) [ = 값 ] ;이는 지역 선언에서 직접 사용되는 바인딩 패턴의 단순한 예시일 뿐이다. 지역 let과 var 선언은 Carbon의 일반적인 패턴 매칭 설계를 기반으로 한다. var 선언은 암묵적으로 var 패턴 안에서 시작한다. let 선언은 기본적으로 값을 바인딩하는 패턴을 도입하는데, 이는 함수 매개변수 및 대부분의 다른 패턴 문맥과 동일하다.
일반적인 패턴 매칭 모델은 더 큰 패턴 내부에 var 하위 패턴을 중첩시키는 것도 허용한다. 예를 들어, 위의 두 지역 선언을 다음과 같이 하나의 구조 분해 선언으로 결합할 수 있다. 여기서 내부에 var 패턴이 있다:
fn DestructuringExample() {
// 1과 2는 모두 값 식으로 시작한다. x 바인딩은 1에 직접 매칭한다.
// 2의 경우, 변수 패턴은 제공된 변수 저장 공간을 초기화하는 초기화 식으로의
// 변환을 요구하며, 그 결과 y가 해당 저장 공간을 참조한다.
let (x: i64, var y: i64) = (1, 2);
// 위와 마찬가지로 y의 주소를 취하고 변경할 수 있다:
MutateThing(&y);
// ❌ 그리고 이것은 여전히 오류다: MutateThing(&x); }
지역 바인딩 패턴에서 타입 대신 auto를 사용하면, 타입 추론을 사용해 변수의 타입을 자동으로 결정한다.
이러한 지역 바인딩은 일반적으로 중괄호({, })로 표시되는 코드 블록 내에서 이름을 도입하며, 해당 블록에 스코프를 가진다.
let 선언의 일부가 var 접두사를 사용해 변수 패턴이 되어 변수의 저장 공간을 참조하는 이름을 바인딩할 수 있는 것처럼, 함수 매개변수도 그렇게 할 수 있다:
fn Consume(var x: SomeData) {
// 여기서 x가 참조하는 변수를 변경하고 사용할 수 있다.
}
이는 함수 입력의 중요한 특수 사례, 즉 함수에 의해 로컬 처리되거나 어떤 영속 저장소로 이동되어 소비되는(consumed) 입력을 모델링할 수 있게 한다. 패턴, 즉 함수 시그니처에서 이를 표시하면 호출자 측 인수에 요구되는 식 분류가 변경된다. 이 인수들은 함수 매개변수에 전용으로 제공되고 함수가 소유하는 저장 공간을 직접 초기화하는 초기화 식이어야 하며, 필요하다면 그렇게 변환되어야 한다.
이 패턴은 비사소적 자원을 가진 타입에서 소유권을 함수로 전달하고 그 자원을 소비하기 위한 C++의 값에 의한 전달(pass-by-value)과 동일한 목적을 수행한다. 그러나 C++에서는 이것이 사실상 기본처럼 보이지만, Carbon에서는 이 사용 사례를 선언에서 특별히 표기해야 한다.
참조 식(reference expression)은 값을 읽거나 쓸 수 있고 객체의 주소를 취할 수 있는 저장 공간을 가진 객체를 참조한다.
참조 식에는 두 가지 하위 분류가 있다: 지속적(durable)과 일시적(ephemeral). 이는 기반 저장 공간의 수명(lifetime)을 정제하며, 해당 수명을 반영한 안전성 제한을 제공한다.
지속적 참조 식은 객체의 저장 공간이 전체 식을 넘어 생존하며, 그 주소를 의미 있게 바깥으로 전파할 수도 있는 경우를 말한다.
Carbon에는 지속적 참조 식이 필요한 문맥이 두 가지 있다:
=의 좌변이 지속적 참조여야 한다. 이 더 강한 요구 사항은 식이 Carbon.Assign.Op 인터페이스 메서드로 디스패치되도록 다시 쓰이기 전에 강제된다.Carbon에서 지속적 참조를 생성하는 식의 종류는 다음과 같다:
x*px.member 또는 p->memberstd::span과 유사하게 IndirectIndexWith를 구현한 타입에 대한 인덱싱, 또는 local_array[i]와 같이 어떤 타입이든 지속적 참조 식에 대한 인덱싱.지속적 참조 식은 오직 이러한 식에 의해 직접적으로만 생성될 수 있다. 다른 식 분류를 참조 식으로 변환하여 생성되지는 않는다.
임시 구체화를 통해 형성된 참조 식을 일시적 참조 식이라고 한다. 이들은 여전히 저장 공간이 있는 객체를 참조하지만, 전체 식을 넘어서 생존하지 않을 저장 공간일 수도 있다. 저장 공간이 일시적이므로, 이러한 참조 식이 사용될 수 있는 곳에 제한을 둔다: 그 주소는 self 매개변수가 ref 지정자로 표시된 메서드 호출의 일부로서만 암묵적으로 취해질 수 있다.
향후 작업: 현재 설계는 C++의 유연성을 복제하기 위해 ref 메서드에 대해 일시적 참조를 직접 요구할 수 있도록 허용한다. C++에서는 L-value-ref 한정 메서드가 매우 드물며, 이는 ref 메서드가 지속적 참조 식을 요구하는 것과 유사한 효과를 낸다. 이는 빌더 API 등 패턴에서 C++에서 자주 활용된다. 다만 Carbon은 이미 이 영역에서 C++보다 더 많은 도구를 제공하므로, 대입과 &와 동일한 제한으로 ref 메서드를 전환할 수 있는지 평가할 가치가 있다. 그렇게 되면 임시 객체의 주소가 (안전한 방식으로) 결코 이스케이프하지 않을 것이며, 서로 다른 종류의 개체 수도 줄어든다. 그러나 이로 인한 표현력 감소가 Carbon 고유 API 설계와 마이그레이션된 C++ 코드에 대해 감내할 만한지 매우 신중하게 판단해야 하므로, 이는 향후 작업으로 남겨 둔다.
값은 변경할 수 없고, 주소를 취할 수 없으며, 아예 저장 공간이나 안정적인 저장 공간의 주소가 없을 수도 있다. 값은 함수 입력 매개변수나 상수와 같은 추상적 구성물이다. 값 식은 리터럴 식(예: 42)로 직접 만들거나, 저장된 객체의 값을 읽어 만들어 낼 수 있다.
Carbon에서 값의 핵심 목표는, 머신 레지스터에 들어갈 만큼 작은 타입을 다룰 때 값에 의한 전달의 효율성을 얻으면서도, 복사가 추가 할당이나 다른 비용이 큰 자원을 필요로 하는 타입에서는 복사를 최소화하는 효율성을 동시에 제공하는 단일 모델을 제공하는 것이다. 이는 함수 입력을 전달하는 메커니즘을 선택하는 모델을 더 단순하게 제공하여 프로그래머를 직접적으로 돕는다. 또한 일관되게 좋은 성능을 내는 단일 타입 모델이 필요한 제네릭 코드가 가능해지는 것도 중요하다.
참조 식으로부터 값 식을 형성할 때, Carbon은 참조된 객체의 값을 획득한다. 이는 객체의 저장 공간에서 값을 즉시 머신 레지스터로 읽어오거나, 원한다면 복사할 수도 있지만, 반드시 그렇게 해야 하는 것은 아니다. 기반 객체의 읽기는 값 식 자체가 사용될 때까지 지연될 수 있다. 한 번 이렇게 객체가 값 식에 바인딩되면, 해당 객체나 그 저장 공간에 대한 모든 변경은 값 바인딩의 수명을 끝내며, 이후 그 값 식의 사용은 오류가 된다.
참고: 이는 결코 "정의되지 않은 동작"이 되도록 의도되지 않았으며, 대신 "오류"가 되도록 의도되었다. 우리는 이러한 코드를 버그로 감지하고 보고할 수 있기를 원하지만, 무제한의 UB를 원하지 않으며, 이것이 중요한 최적화를 방해한다는 근거도 알지 못한다.
미해결 이슈: 여기(그리고 다른 곳)에서 사용할 수 있는 오류 동작의 공통 정의가 필요하다. 이를 마련하면 여기에 인용해야 한다.
참고: 이 제한은 또한 실험적이다. 특히 C++ 상호 운용 및 더 완전한 메모리 안전성 이야기와 함께, 경험에 따라 강화하거나 완화할 수 있다.
이러한 제한에도 불구하고, 우리는 Carbon의 값이 C++의 const &가 유용한 거의 모든 곳에서 비슷하게 유용하되, 값이 머신 레지스터에 보관될 수 있을 때 추가적인 효율성을 제공할 것으로 기대한다. 또한 우리는 특히 추가 효율성이 있는 const &에 대한 심상 모델을 권장한다.
값이 바인딩될 때, 특히 함수 경계를 가로질러 실제 "표현(representation)"은 타입에 의해 커스터마이즈될 수 있다. 기본값은 C++의 const &의 기준 효율성을 보존하는 데 기반을 두지만, 올바르고 안정적으로 더 효율적인 경우(예: 머신 레지스터로 읽기) 값을 읽을 수 있다.
Carbon에서 이것들을 "값"이라고 부르지만, 이는 C++의 by-value 매개변수와 관련이 없다. C++의 by-value 매개변수 의미론은 인자의 새 로컬 복사본을 생성하도록 정의되어 있으며, 이 복사본으로 이동할 수는 있다.
Carbon의 값은 C++의 const &와 훨씬 더 가깝다. 다만 "as-if" 하에서 복사를 허용하고 주소 취득을 방지하는 등의 추가 제한이 있다. 이러한 제한이 결합되어 레지스터 내 매개변수 같은 구현 전략이 가능해진다.
값 식과 값 바인딩은 다형 타입과 함께 사용할 수 있다. 예:
base class MyBase { ... }
fn UseBase(b: MyBase) { ... }
class Derived { extend base: MyBase; ... }
fn PassDerived() {
var d: Derived = ...;
// 여기서 d를 전달할 수 있다:
UseBase(d);
}
여전히 복사나 이동이 발생할 수는 있지만, 슬라이싱해서는 안 된다. 설령 복사가 만들어지더라도, 그것은 Derived 객체여야 하며, 이는 사용 가능한 구현 전략을 제한할 수 있다.
향후 작업: 사용자 정의 값 표현과 다형 타입에서의 값 식 사용 사이의 상호 작용을 완전히 정리해야 한다. 슬라이싱을 방지하려면
const ref스타일 표현으로 제한해야 하거나, 다른 값 표현이 사용될 때의 의미론을 모델링해야 할 수도 있다.
const & 및 const 메서드와의 상호 운용성
값 식은 Carbon에서 주소를 취할 수 없지만, C++의 const & 및 const 한정 메서드와 상호 운용 가능해야 한다. 이는 사실상 어떤 객체(잠재적으로 복사본이나 임시 객체)를 메모리에 "고정"하고 C++이 그 주소를 취할 수 있도록 한다. 이를 지원하지 않으면 값이 상호 운용성에서 심각한 인체공학적 장벽을 만들 수 있다. 그러나 이는 값 식에 추가 제약을 만들고, 주소가 예상치 못하게 이스케이프할 수 있는 길을 만든다.
상호 운용을 구현하려면 주소가 필요함에도, C++은 const & 매개변수가 호출된 함수가 반환된 후에는 별 의미가 없고 유효하지 않을 수도 있는 임시 객체에 바인딩되는 것을 허용한다. 결과적으로, const &를 사용하는 C++ 인터페이스가 실제로 오동작할 것으로 보이지는 않는다.
향후 작업: 타입이 값 표현을 커스터마이즈하면, 현재 명세대로는 그러한 값을
const &C++ API에서 사용하는 것이 깨질 수 있다. 값 표현 커스터마이징 규칙을 확장하여, 표현 타입이 커스터마이즈된 타입의 (복사)로 변환 가능하거나, 표현 객체를 형성하는 데 사용된 원래 객체에 대한const포인터를 계산하는 상호 운용 전용 인터페이스를 구현하도록 요구해야 한다. 이렇게 하면 사용자 정의 표현이 상호 운용을 위해 복사본을 만들거나 원래 객체에 대한 포인터를 유지하고 이를 노출하도록 선택할 수 있다.
또 다른 위험은 Carbon의 값 식을 const & 매개변수에 노출하는 것이다. C++에서는 const를 제거하는 캐스팅이 가능하기 때문이다. 그러나 mutable 멤버가 없는 한, const & 매개변수(또는 const 한정 메서드)를 통해 변경하는 것은 const를 제거한다고 해서 안전해지지 않는다. C++은 const & 매개변수와 const 멤버 함수가 선언이 const인 객체에 접근하는 것을 허용한다. 이러한 객체는 const를 제거하더라도 변경할 수 없으며, 이는 Carbon의 값 식과 정확히 동일하다. 실제 구현에서는 이러한 종류의 변경이 깨진다. 결과적으로, Carbon의 값 식은 C++에서 const로 선언된 객체와 유사하게 동작하며, C++ 코드와도 유사하게 상호 운용될 것이다.
미해결 질문: 값의 주소를 취하기 위한 어느 정도의 이스케이프 해치를 제공해야 할 수도 있다. 위의 C++ 상호 운용은 이미 기능적으로 그들의 주소를 취한다. 현재로서는 이것이 값에 대한 제한에 대한 이스케이프 해치의 전부다.
추가 이스케이프 해치가 필요하다면, 이러한 기본적 의미론 약화는 Rust의 unsafe 같은 문법적 마커의 좋은 사용 사례가 될 수 있다. 다만 영역(region) 보다는 해당 연산 자체에 직접 연결하는 것이 더 좋아 보인다. 예를 들어:
class S { fn ValueMemberFunctionself: Self; fn RefMemberFunctionref self: const Self; }
fn F(s_value: S) { // 이것은 괜찮다. s_value.ValueMemberFunction();
// 이것은 문법에서 unsafe 마커가 필요하다. s_value.unsafe RefMemberFunction(); }
이와 관련된 구체적인 트레이드오프는 제안서의 대안에서 다룬다.
Carbon의 저장 공간은 초기화 식(initializing expression)을 사용해 초기화된다. 이를 계산하면 저장 공간에 초기화된 객체가 생성되지만, 그 객체가 아직 미성립(unformed) 상태일 수도 있다.
향후 작업: 초기화와 미성립 객체에 대한 더 자세한 내용은 제안서 #257에서 설계로 추가되어야 하며, #1993를 보라. 추가되면, 초기화 의미론의 세부 사항에 대해서는 여기에 링크해야 한다.
가장 단순한 형태의 초기화 식은 초기화 식으로 변환되는 값 식이나 지속적 참조 식이다. 값 식은 저장 공간에 직접 기록되어 새 객체를 형성한다. 참조 식은 참조하는 객체를 제공된 저장 공간의 새 객체로 복사한다.
향후 작업: 복사가 어떻게 관리되는지에 대한 설계를 완전히 확장하고, 여기에서 링크해야 한다.
초기화 식이 필수인 첫 번째 장소는 변수 패턴을 만족시킬 때다. 이는 자신들이 생성하는 저장 공간에 대한 초기화 식이 대응되는 식이 되도록 요구한다. 가장 단순한 예는 지역 var 선언에서 = 뒤의 식이다.
Carbon 식에서 초기화 식이 필요한 다음 장소는 return 문의 피연산자 식이다. 반환문이 식, 값, 객체, 저장 공간과 어떻게 상호 작용하는지에 대해서는 아래에서 보다 완전하게 확장한다.
Carbon에서 초기화 식을 형성해야 하는 마지막 경로는, 비참조 식을 일시적 참조 식으로 변환하려고 할 때다. 이 경우 식은 필요하다면 먼저 초기화 식으로 변환되고, 그 결과를 위한 임시 저장 공간이 구체화되어, 결과 일시적 참조 식의 피참조체(referent)로 사용된다.
Carbon에서 함수 호출은 초기화 식으로 직접 모델링된다. 즉, 입력으로 저장 공간을 필요로 하며, 계산되면 그 저장 공간에 객체를 초기화한다. 따라서 다음과 같이 함수 호출이 어떤 변수 패턴을 초기화하는 데 사용될 때:
fn CreateMyObject() -> MyType { return <return-expression>; }
var x: MyType = CreateMyObject();
return 문 안의 <return-expression>이 실제로 x를 위한 저장 공간을 초기화한다. "복사"나 다른 단계는 없다.
향후 작업: 변수 패턴이 튜플/구조체 리터럴로부터 초기화되거나, 변수 하위 패턴을 가진 튜플/구조체 패턴이 단일 함수 호출로부터 초기화될 때에도 동일하게 확장한다.
모든 return 문 식은 초기화 식이어야 하며, 실제로 함수 호출 식에 제공된 저장 공간을 초기화한다. 이는 차례로 임의의 수의 함수 호출과 반환을 가로질러 이 성질이 추이적으로 성립하게 한다. 저장 공간이 각 단계에서 전달되며 정확히 한 번만 초기화된다.
명시적 반환 타입이 없는 함수는 식 분류의 목적상 () 반환 타입을 가진 함수와 정확히 동일하게 동작한다.
TODO: 이 섹션은 제안 #5434의 -> val 반환 추가를 반영하도록 업데이트되어야 한다. 이 섹션은 초기화 반환이 안전하고 올바른 경우 값 반환으로 대체될 수 있다는 설명으로 대체하고, 많은 내용을 값 반환의 동작 설명으로 옮길 수 있다.
Carbon은 효율성 향상을 가능하게 하기 위해 함수 호출과 반환문의 평가를 밀접하게 연결한다. return 문이 그 식으로 수행하는 실제 초기화를, 함수 본문 내부에서 호출자 초기화 식으로 지연시킬 수 있도록 허용한다. 이는 호출자에게 살아 있고 사용 가능함이 보장되는 값 또는 참조 식을 단순히 전파할 수 있는 경우에 해당된다.
다음 코드를 보자:
fn SelectSecond(first: Point, second: Point, third: Point) -> Point { return second; }
fn UsePoint(p: Point);
fn F(p1: Point, p2: Point) { UsePoint(SelectSecond(p2, p1, p2)); }
SelectSecond 호출은 초기화할 수 있는 Point용 저장 공간을 제공해야 한다. 그러나 Carbon은 실제 SelectSecond 함수 구현이 return second에 도달했을 때 이 저장 공간을 초기화하지 않아도 되도록 허용한다. 식 second는 호출의 인자 값 식에 바인딩된 이름이며, 그 값 식은 호출자에서 반드시 유효하다. 이 경우 Carbon은 구현이 반환되는 식이 호출의 특정 값 식 인자에 바인딩된 이름임을 단순히 전달하고, 호출자는 필요하다면 임시 저장 공간을 초기화해야 한다고 전달하는 것을 허용한다. 이는 차례로 호출자 F가 값 식 인자(p1)가 이미 UsePoint의 인수로 전달하기에 유효하다는 것을 인식하여, 그 값으로부터 임시 저장 공간을 초기화하고 다시 그 저장 공간에서 읽어오는 과정을 생략할 수 있게 한다.
이는 타입 시스템에 영향을 주지 않으므로, 구현은 구체 타입에 따라 특정 전략을 자유롭게 선택할 수 있으며, 제네릭 코드를 해치지 않는다. 심지어 제네릭이 단형화 없이(예: 객체 안전 인터페이스의 동적 디스패치) 구현되더라도, 어떤 타입에 대해서도 보수적으로 올바른 전략이 존재한다.
이 자유도는 입력 값과 유사하다. 입력 값도 참조 기반 또는 복사 기반 구현 어느 쪽이든 제네릭성을 깨뜨리지 않는다. 여기서도 많은 작은 타입은 지연이 필요 없고, 실제 머신 레지스터로 구현되는 임시를 즉시 초기화하면 된다. 하지만 큰 타입이나 할당된 저장 공간이 연관된 타입에서는, 불필요한 메모리 할당과 기타 비용을 안정적으로 피할 수 있다.
이 유연성은 호출 식이 임시 저장 공간을 구체화하고 그것을 함수에 제공하는 것을 피하지는 않는다. 함수가 이 저장 공간을 필요로 하는지는 구현 세부사항이다. 이는 단지 호출자에게 이미 사용 가능한 값 또는 참조 식으로부터 그 저장 공간을 초기화하는 중요한 경우를 호출자로 지연시켜, 해당 초기화가 필요하지 않은 경우를 식별할 수 있게 할 뿐이다.
참고: 이는 반환으로 인해 발생할 수 있는 불필요한 복사를 줄이는 것에 관한 리드 이슈를 다룬다.
returned 변수
반환의 초기화 모델은 returned var 선언의 사용도 용이하게 한다. 이는 함수 반환의 초기화를 위해 제공된 저장 공간을 직접 관찰한다.
Carbon의 포인터는 어떤 값이 들어있는 저장 공간에 대한 간접 접근(indirect access)의 주된 메커니즘이다. 포인터의 역참조는 지속적 참조 식을 형성하는 주된 방법 중 하나다.
Carbon의 포인터는 C++의 포인터에 비해 강하게 제한된다. null이 될 수 없고, 인덱싱이나 포인터 연산을 수행할 수 없다. 어떤 면에서는 C++의 참조에 더 가깝지만, 포인터가 가진 본질적인 측면, 즉 포인터를 가진 쪽(point-er)과 가리켜지는 쪽(point-ee)을 문법적으로 구분한다는 점은 유지한다.
Carbon은 여전히 C++ 포인터와 동등한 동작을 달성할 수 있는 메커니즘을 제공할 것이다. 옵션 포인터는 null 가능 사용 사례를 제공할 것으로 기대된다. 슬라이스나 뷰 스타일의 타입은 인덱싱 가능한 영역에 대한 접근을 제공할 것이다. 심지어 원시 포인터 연산도, 이 연산의 특수한 성격을 감안하여 특수화된 구성물을 통해 어느 시점에 제공될 것으로 예상된다.
향후 작업: 이러한 사용 사례에 대한 명시적 설계를 추가하고 여기에 링크한다.
TODO: 이 섹션은 제안 #5434를 반영하도록 업데이트되어야 한다.
C++와 달리, Carbon에는 현재 참조 타입이 없다. 간접 접근의 유일한 형태는 포인터다. 이 결정에는 분리해서 생각해야 하는 몇 가지 측면이 있으며, 각각의 동기와 고려 사항이 다르다.
첫째, Carbon은 간접 타입 시스템에 수명 주석이나 기타 안전/최적화 메커니즘 같은 더 강력한 제어를 추가하고 구성해야 할 때, 확장 지점이 하나만 있도록 기본적인 간접 구성물을 하나만 가진다. 설계는 단일 핵심 간접 도구를 식별하고 그 위에 다른 관련 사용 사례를 레이어링하려고 한다. 이는 언어가 진화하면서 확장 가능하도록 하고, C++가 이 영역에서 가지는 엄청난 복잡성 폭발(동등한 간접 표현이 N>1개이고, API가 M개의 서로 다른 매개변수 전반에서 그 어떤 것이든 수용하려 하면 N*M 조합이 생김)을 줄이려는 동기에서 비롯된다.
둘째, 포인터를 사용함으로써 Carbon의 간접 메커니즘은 필요할 때 포인터를 가진 쪽(point-er)과 가리켜지는 쪽(point-ee)을 명확히 구분해 참조할 수 있는 능력을 유지한다. 이는 재바인딩을 지원하는 데 매우 중요하며, 이 특성이 없다면 더 많은 간접성 변형이 등장할 가능성이 크다.
셋째, Carbon은 간접 접근과 직접 접근 사이의 문법적 구분을 피할 수 있는 직선적 방법을 제공하지 않는다.
이러한 설계 결정의 트레이드오프 전체에 대한 논의는 P2006의 대안 고려 섹션을 참고하라:
타입 T에 대한 포인터 타입은 후위 *로 T*처럼 쓴다. 포인터의 역참조는 [참조 식]이며, 접두 *로 *p처럼 쓴다:
var i: i32 = 42; var p: i32* = &i;
// 참조 식 *p를 형성하고, 참조된 저장 공간에 13을 대입한다.
*p = 13;
이 구문은 코드에서 일반적으로 쓰이는 C++ 포인터 타입과 최대한 유사하게 유지하도록 특별히 선택되었으며, 이는 언어 간 구문 유사성의 핵심 앵커가 될 정도로 매우 흔할 것으로 예상되기 때문이다. 이 구문 이슈에 대한 다양한 대안과 트레이드오프는 #523에서 폭넓게 논의되었고, 제안서에 요약되어 있다.
Carbon은 C++과 유사한 중위 -> 연산도 지원한다. 다만 Carbon은 이를 *와 .으로의 정확한 치환으로 직접 정의한다. 예를 들어 p->member는 (*p).member가 된다. 즉, C++에 있는 것처럼 오버로드되거나 커스터마이즈 가능한 -> 연산자는 Carbon에는 없다. 대신, *p의 동작을 커스터마이즈하면 그에 따라 p->의 동작도 커스터마이즈된다.
향후 작업: #523에서 논의되듯, C++ 구문의 주요 문제 중 하나는 접두 역참조 연산과 다른 후위/중위 연산의 합성, 특히 (*(*p)[42])[13]처럼 역참조와 인덱싱이 섞여 연쇄되는 경우의 고전적 좌절이다. 이러한 합성이 인체공학적 문제를 일으킬 만큼 충분히 흔한 경우, 그룹화된 역참조로 내려쓰는 맞춤 구문을 -> 유사 방식으로 도입할 계획이다. 다만 현재로서는 -> 자체 외에는 아무 것도 제공되지 않는다. 이를 확장하는 것, 정확한 설계와 범위는 향후 작업 영역이다.
Carbon은 사용자 정의 포인터 유사 타입(스마트 포인터 등)을 연산자 오버로딩이나 기타 식 구문과 유사한 패턴으로 지원해야 한다. 즉, 식을 인터페이스의 멤버 함수 호출로 다시 쓴다. 그런 다음 타입은 이 인터페이스를 구현하여 포인터 유사한 사용자 정의 역참조 구문을 노출할 수 있다.
인터페이스는 다음과 같을 수 있다:
interface Pointer { let ValueT:! Type; fn Dereferenceself: Self -> ValueT*; }
아래는 포인터를 모방하는 포인터 옆에 정수 태그를 추가로 담는 가상의 TaggedPtr을 사용하는 예다:
class TaggedPtr(T:! Type) { var tag: Int32; var ptr: T*; } external impl [T:! Type] TaggedPtr(T) as Pointer { let ValueT:! T; fn Dereferenceself: Self -> T* { return self.ptr; } }
fn Test(arg: TaggedPtr(T), dest: TaggedPtr(TaggedPtr(T))) { **dest = *arg; *dest = arg; }
여기에는 까다로운 점이 하나 있다. 포인터 유사 역참조를 구현하는 인터페이스의 함수는 언어가 실제로 역참조하여 var 선언과 유사한 참조 식을 형성할 수 있도록 원시 포인터(raw pointer)를 반환해야 한다. 이 인터페이스는 일반 포인터에 대해서는 no-op으로 구현된다:
impl [T:! Type] T* as Pointer { let ValueT:! Type = T; fn Dereferenceself: Self -> T* { return self; } }
*x 같은 역참조 식은 이 인터페이스를 사용해 원시 포인터를 얻은 뒤, 그 원시 포인터를 언어 차원에서 역참조하도록 구문적으로 다시 쓰인다. 언어 차원의 역참조가 참조 식을 형성하는 단항 deref 연산자라고 상상하면, (*x)는 (deref (x.(Pointer.Dereference)()))가 된다.
Carbon은 또한 x->Method()를 별도의 커스터마이징 없이 (*x).Method()로 단순 구문 치환을 사용해 구현한다.
const 한정 타입
Carbon은 키워드 const로 타입 T를 한정하여 const T라는 const 한정 타입을 제공한다. 이는 Carbon에서 오로지 API 서브셋팅을 위한 기능이다. 보다 근본적으로 "불변"의 용도에는 대신 값 식과 바인딩을 사용해야 한다. Carbon에서 const 한정 타입에 대한 포인터는, 스레드 호환(thread-compatible) 타입의 스레드 안전(thread-safe) 인터페이스 서브셋만을 통해 사용을 보장해야 하는 중요한 요구 사항을 모델링하는 데 도움이 되는, API 서브셋을 가진 객체에 대한 접근을 제공한다.
const T는 타입 한정이며, 일반적으로 식 분류나 어떤 형태의 패턴(객체 매개변수를 포함)에 대해 직교적이다. 개념적으로, 이는 ref와 값 객체 매개변수 모두에서 발생할 수 있다. 그러나 값 패턴에서는 T 타입의 값 식과 const T 타입의 값 식 사이에 의미 있는 차이가 없으므로, 중복된다. 예를 들어 다음과 같은 타입과 메서드가 있다고 하자:
class X { fn Methodself: Self; fn ConstMethodself: const Self; fn RefMethodref self: Self; fn RefConstMethodref self: const Self; }
메서드는 다음 표에 따라 서로 다른 종류의 식에서 호출될 수 있다:
| 식 분류: | let x: X (값) | let x: const X (const 값) | var x: X (참조) | var x: const X (const 참조) |
|---|---|---|---|---|
x.Method(); | ✅ | ✅ | ✅ | ✅ |
x.ConstMethod(); | ✅ | ✅ | ✅ | ✅ |
x.RefMethod(); | ❌ | ❌ | ✅ | ❌ |
x.RefConstMethod() | ❌ | ❌ | ✅ | ✅ |
const T 타입은 T와 동일한 표현(필드명 동일)을 가지지만, 모든 필드 타입도 const로 한정된다. 필드를 제외하면, T의 다른 모든 멤버는 const T의 멤버이기도 하며, impl 탐색은 const 한정을 무시한다. T에서 const T로의 암묵 변환은 있지만, 그 역은 없다. 참조 식을 값 식으로의 변환은 const T 참조 식을 T 값 식으로 변환하는 방식으로 정의된다.
const T는 주로 포인터의 일부로 등장할 것으로 예상된다. 목적이 본질적으로 참조 식을 형성하는 것이기 때문이다. 연산자 우선순위도 이 흔한 경우를 위해 설계되었는데, const T*는 (const T)*, 즉 const에 대한 포인터(포인터-투-콘스트)를 의미한다. Carbon은 const 한정 타입에 대한 포인터 간의 변환을 지원하며, 이는 C++에서 const 한정의 우발적 손실을 피하기 위해 사용하는 규칙과 동일하다.
const의 구문 세부사항은 타입 연산자 문서에서도 다룬다.
Carbon의 이러한 설계에서 명백히 또는 완전히 다루어지지 않는 잠재적 사용 사례 중 하나는, 인수의 수명을 관찰하여 함수 호출을 오버로드하는 것이다. 여기서의 사용 사례는 어떤 인수의 수명이 마침 끝나서 move-from이 가능할 때 동일한 함수나 연산에 대해 다른 구현 전략을 선택하는 것이다.
Carbon은 현재 의도적으로 이 사용 사례를 다루지 않는다. 이 스타일의 오버로딩에는 근본적인 확장성 문제가 있다. 간접성 모델의 다른 순열과 유사하게 가능한 오버로드 수의 조합 폭발을 만든다. 수명 오버로딩의 이점을 받는 매개변수가 N개인 함수를 생각해 보자. 각 매개변수가 서로 독립적으로 이점을 받는 것이 일반적인 경우, 모든 가능성을 표현하려면 2^N 개의 오버로드가 필요하다.
Carbon은 초기에는 이 기능 없이도 코드가 설계될 수 있는지 확인할 것이다. 이를 회피하기 위해 필요한 도구 중 일부는 위에서 언급한 소비형 입력 패턴과 같다. 하지만 실제로 더 많은 것이 필요할 수도 있다. 이 제안의 도구로는 표현할 수 없는 구체적이고 현실적인 Carbon 코드 패턴을 식별하여 최소 확장을 동기화하는 것이 좋다. 여기서 이미 제안되었거나 클래스에 대해 제안된 기능을 바탕으로 한 후보들은 다음과 같다:
ref self와 self 간 오버로딩을 허용한다. 이는 조합 폭발이 생기지 않기 때문에 가장 매력적이며, 다만 암묵 객체 매개변수에만 적용되므로 매우 제한적이다.var 매개변수와 비-var 매개변수 간 오버로딩을 허용한다.ref 기법을 모든 매개변수로 확장하고, 그에 따른 오버로딩을 허용한다.아마 더 많은 옵션이 떠오를 수도 있다. 다시 말해, 이 방향을 완전히 배제하는 것이 목표가 아니라, 실제이고 구체적인 필요에 기반하여 이를 추구하고, 최소한의 확장을 채택하도록 하려는 것이다.
값 식의 표현은 특히 중요하다. 이는 대다수의 함수 매개변수(함수 입력)에 사용되는 호출 규약을 형성하기 때문이다. 이 중요성을 감안하여, 값의 타입에 의해 예측 가능하고 커스터마이즈 가능해야 한다. 마찬가지로, Carbon 코드는 복사 기반 또는 참조 기반 구현 어느 쪽이든 올바르게 동작해야 하지만, 어떤 구현 전략이 사용되는지는 값의 타입의 예측 가능하고 커스터마이즈 가능한 속성이길 원한다.
타입은 소멸자를 커스터마이즈하는 것과 유사한 사용자 정의 구문을 사용하여 값 표현을 선택적으로 제어할 수 있다. 이 구문은 표현을 어떤 타입으로 설정하며, 키워드 value_rep를 사용하고 타입 내에서 멤버 선언이 유효한 곳에 나타날 수 있다:
class SomeType { value_rep = RepresentationType; }
미해결 질문: 이는 단지 플레이스홀더 구문과 키워드로서의 자리표시자일 뿐이다. 전혀 최종이 아니며, 자연스럽게 읽히도록 변경해야 할 가능성이 높다.
제공된 표현 타입은 다음 중 하나여야 한다:
const Self — 이는 객체의 복사 사용을 강제한다.const ref — 이는 원래 객체에 대한 포인터를 사용하게 하지만, const API 서브셋으로 제한한다.Self, const Self, 또는 이 둘에 대한 포인터가 아닌 사용자 정의 타입.표현이 const Self 또는 const ref인 경우, 해당 타입의 값 식에 대해 정상적인 멤버 접근 구문으로 값 식으로 필드에 접근할 수 있다. 이는 비포인터의 경우 객체 복사본을, 포인터의 경우 원래 객체에 대한 포인터를 사용하여 구현된다. const Self 표현은 타입에 대해 복사가 유효할 것을 요구한다. 이는 내장 기능을 제공하지만, 사용될 표현을 명시적으로 제어하게 한다.
커스터마이징이 제공되지 않으면, 구현은 일련의 휴리스틱에 기반하여 하나를 선택한다. 몇 가지 예:
const ref를 사용한다.const Self를 사용한다.사용자 정의 타입이 제공될 때, 이는 Self, const Self, 또는 이 둘에 대한 포인터여서는 안 된다. 제공된 타입은 함수 호출 경계에서, 그리고 타입의 값 바인딩 및 기타 값 식의 구현 표현으로 사용된다. value_rep = T; 지정은 해당 지정이 포함된 타입이 다음 인터페이스를 사용하여 impls ReferenceImplicitAs where .T = T 제약을 만족할 것을 요구한다:
interface ReferenceImplicitAs { let T:! type; fn Convertref self: const Self -> T; }
이러한 타입에 대해 참조 식을 값 식으로 변환할 때, 이 커스터마이제이션 포인트를 호출하여 원래 참조 식으로부터 표현 객체를 형성한다.
이렇게 사용자 정의 표현 타입을 사용할 때, 값 식을 통해서는 필드에 접근할 수 없다. 대신, 멤버 접근으로 호출할 수 있는 것은 메서드뿐이다. 다만 한 가지 중요한 메서드는 호출할 수 있다 — .(ImplicitAs(T).Convert)(). 이는 해당 타입의 값 식을 사용자 정의 표현 타입으로 암묵 변환한다. 위의 표현 커스터마이즈와 impls ReferenceImplicitAs where .T = T는, 클래스가 표현 타입으로의 변환을 no-op으로 수행하는 내장 impl as ImplicitAs(T)를 가지도록 하며, 원래 참조 식에서 ReferenceImplicitAs.Convert를 호출해 생성된 객체가 값 식의 표현으로서 보존되어 노출된다.
아래는 이러한 기능을 사용하는 보다 완전한 코드 예시다:
class StringView { private var data_ptr: Char*; private var size: i64;
fn Make(data_ptr: Char*, size: i64) -> StringView { return {.data_ptr = data_ptr, .size = size}; }
// 전형적인 읽기 전용 문자열 뷰 API... fn ExampleMethodself: Self { ... } }
class String {
// 값 표현을 StringView로 커스터마이즈한다.
value_rep = StringView;
private var data_ptr: Char*; private var size: i64;
private var capacity: i64;
impl as ReferenceImplicitAs where .T = StringView {
fn Opref self: const Self -> StringView {
// 이 메서드는 String 객체가 값이 되기 전에 호출되므로,
// self의 SSO 버퍼나 기타 내부 포인터에 접근할 수 있다.
return StringView.Make(self.data_ptr, self.size);
}
}
// self를 StringView로 받는 메서드를 직접 선언할 수 있으며,
// 이는 호출자가 호출 전에 값 식을 StringView로 암묵 변환하도록 만든다.
fn ExampleMethodself: StringView { self.ExampleMethod(); }
// 또는 일반적인 것처럼 self에 값 바인딩을 사용할 수도 있지만,
// 사용자 정의 값 표현 때문에 구현에는 제약이 생긴다.
fn ExampleMethod2self: String {
// 사용자 정의 값 표현으로 인해 오류:
self.data_ptr;
// 괜찮다. 이는 내장 `ImplicitAs(StringView)`를 사용한다.
(self as StringView).ExampleMethod();
}
// 여기서 Self 타입이 const로 한정되어 있지만,
// 이것은 String 값에서 호출될 수 없다! 그러려면 추가 데이터 멤버를 추적하지 않는
// StringView로의 변환이 필요하기 때문이다.
fn Capacityref self: const Self -> i64 {
return self.capacity;
}
}
값 식의 "표현" 타입은 어디까지나 표현일 뿐이며, 이름 조회나 타입에는 영향을 주지 않는다는 점이 중요하다. 이름 조회와 impl 탐색은 식 분류와 무관하게 동일한 타입에 대해 발생한다. 하지만 특정 메서드나 함수가 선택되면, 원래 타입에서 표현 타입으로의 암묵 변환이 매개변수 또는 수신자 타입의 일부로 발생할 수 있다. 실제로, 이 변환은 값의 타입이 사용자 정의 값 표현을 가질 때 수행할 수 있는 유일한 연산이다.
위 예시는 또한 이러한 방식으로 타입의 값 표현을 커스터마이즈할 때 발생하는 근본적인 트레이드오프를 보여준다. 큰 제어력을 제공하지만, 놀라운 제한을 초래할 수 있다. 위에서는 C++의 const std::string&에서 고전적으로 가능한, 용량(capacity)을 조회하는 메서드조차도, 추가 상태에 접근할 수 없게 되므로 사용자 정의 값 표현으로는 구현할 수 없다. Carbon은 타입 저자가 제한된 API로 작업하고 사용자 정의 값 표현을 활용할지 여부를 명시적으로 선택할 수 있게 한다.
미해결 질문: 현재 자리표시자인 value_rep = T;를 사용하는 특정 구문을 넘어, 커스터마이제이션 포인트와의 최적의 관계를 탐색해야 한다. 예를 들어, 이 구문이 즉시 impl as ReferenceImplicitAs where .T = T를 forward declare하여, Convert 메서드의 선언을 외부로 분리할 수 있게 하고, ... where _가 구문에서 연관 상수를 가져오도록 해야 할까? 대안으로, 문법 마커를 ReferenceImplicitAs 자체의 impl 선언에 통합할 수도 있다.
var 도입 키워드 제거var 문 도입자의 이름auto 대신 타입 생략