플레이싱 함수

ko생성일: 2025. 9. 4.갱신일: 2025. 9. 9.

호출자 스택 프레임에 값을 제자리로 생성하는 ‘플레이싱 함수’ 개념을 소개하고, 디슈가링, Rust와 C++의 선행 사례, 수명 확장·Pin·인자 배치 등 Q&A를 통해 언어 통합 방안을 논의합니다.

플레이싱 함수

— 2025-07-08

  1. 플레이싱 함수란?

  2. 기본 디슈가링

  3. 플레이싱 함수로 사고하기

  4. Rust의 선행 사례

  5. Q&A 1. 플레이싱 인자(인수)는 어떡하죠? 2. 대여/로컬 수명 확장은요? 3. Pinning은요? 4. 애너테이션이 꼭 필요할까요? 5. 자기 참조 타입은요? 6. Init<T>&out에 바로 의존하면 안 되나요? 7. 플레이싱 함수를 중첩할 수 있나요?

  6. 결론

플레이싱 함수란?

약 1년 전 저는 제자리 생성이 놀라울 만큼 단순해 보인다는 것을 관찰했습니다. 메모리 상의 “자리(place)”를 만드는 것과 그 자리에 값을 쓰는 것을 분리해 보면, 이를 언어 기능으로 바꾸는 방법이 그리 어렵지 않다는 걸 볼 수 있습니다. 그래서 약 6개월 전, 저는 실제로 그렇게 해 보았고, “플레이싱 함수(placing functions)”에 대한 프로시저 매크로 기반 프로토타입인 placing 크레이트를 만들었습니다.

플레이싱 함수는 반환 타입이 함수의 스택 프레임이 아닌 호출자의 스택 프레임에서 구성되는 함수입니다. 이는 곧 생성 순간부터의 주소가 “안정적”이라는 뜻입니다. 이는 성능 향상을 이끌 뿐만 아니라, dyn AFITs(트레이트 안의 비동기 함수, Async Functions in Traits) 1 같은 여러 유용한 기능의 토대가 되기도 합니다.

이 글에서는 플레이싱 함수가 어떻게 디슈가링되는지, 왜 플레이싱 함수가 제자리 배치(emplacement)의 올바른 해법인지, 그리고 플레이싱 함수가 언어에 어떻게 통합되는지를 설명합니다. 실제 RFC만큼 자세하게는 아니고, 아이디어 차원의 넓은 소개에 가깝습니다. 바로 예제로 들어가 보죠:

struct Cat {
    age: u8,
}

impl Cat {
    #[placing]       // ← 함수를 "플레이싱"으로 표시합니다.
    fn new(age: u8) -> Self {
        Self { age } // ← `Self`를 호출자 프레임에 구성합니다.
    }

    fn age(&self) -> &u8 {
        &self.age
    }
}

fn main() {
    let cat = Cat::new(12); // ← `Cat`이 제자리로 생성됩니다.
    assert_eq!(cat.age(), &12);
}

기본 디슈가링

placing 크레이트의 목적은 플레이싱 함수가 구현하기 그리 어렵지 않음을 증명하는 데 있습니다. 저는 겨울 방학 몇 시간 만에 작동하는 프로토타입을 만들 수 있었습니다. 총합으로 따지면 구현에 사흘에서 나흘 정도를 쓴 것 같습니다. 저는 컴파일러 엔지니어는 아니고, T-Compiler 분들이라면 아마 제 시간의 일부분만으로도 재현할 수 있으리라 생각합니다.

rustc 프런트엔드를 잘 몰랐기에, 저는 placing 크레이트를 전적으로 프로시저 매크로만으로 구현했습니다. 장점은 빨리 작동하는 걸 만들 수 있었다는 점입니다. 단점은 프로시저 매크로는 타입 정보에 접근할 수 없다는 점입니다. 그래서 그 한계를 우회해야 했고, 그 결과 많은 proc 매크로 애트리뷰트를 요구하는 API가 만들어졌습니다. 하지만 개념 증명에는 괜찮습니다.

앞서 보인 기본 예제를 proc 매크로를 사용해 다시 걸어가 봅시다. 먼저 placing 크레이트를 설치합니다:

$ cargo add placing

그다음 placing을 임포트하고 메인 구조체 Cat을 정의합니다. 내부 표현을 약간 바꿔야 하므로 #[placing] 애트리뷰트를 붙여야 합니다. 이렇게 됩니다:

//! 원본

use placing::placing;

#[placing]
pub struct Cat {
    age: u8,
}

#[placing] 애너테이션이 무엇으로 확장되는지 봅시다. 서두에서 말했듯이: 플레이싱 함수는 메모리 위치의 생성과 그 위치에 값들을 초기화하는 것을 분리합니다. 우리 타입 Cat의 위치는 MaybeUninit<Cat> 타입이어야 합니다. 하지만 외부 타입은 그대로 두면서 내부 동작만 바꾸고 싶습니다. 그래서 바깥 타입 이름은 Cat으로 두고, 필드들을 내부의 MaybeUninit로 옮깁니다:

//! 디슈가링

use std::mem::MaybeUninit;

/// 외부 타입은 동일하게 유지하되,
/// 내부는 필드들을 `MaybeUninit` 안에
/// 담도록 변경합니다.
#[repr(transparent)]
pub struct Cat(MaybeUninit<InnerCat>);

/// 원래의 `Cat` 타입에 들어 있던 필드들입니다.
/// 내부에서 `MaybeUninit`로 감싸기 위해
/// 분리해 둡니다.
struct InnerCat {
    age: u8,
}

디슈가링은 타입 정의에 한 가지를 더 추가해야 올바르게 동작합니다. 이제 MaybeUninit을 보유하게 되었으므로 드롭 시 파괴자가 호출되도록 보장해야 합니다. 이를 위해 MaybeUninit을 통해 호출하는 Drop 구현을 생성해야 합니다.

//! 디슈가링

impl Drop for Cat {
    fn drop(&mut self) {
        // 생성자들이 초기화 전에 드롭되지 않음을 보장합니다.
        unsafe { self.0.assume_init_drop() }
    }
}

이제 타입 정의를 갖추었으니, new 생성자를 어떻게 구현하는지 봅시다. 타입 정보에 접근할 수 없기 때문에 placing 크레이트는 impl 블록과 메서드 양쪽에 애너테이션을 요구합니다:

//! 원본

#[placing]
impl Cat {
    #[placing]
    fn new(age: u8) -> Self {
        Self { age }
    }
}

여기가 디슈가링에서 가장 까다로운 부분입니다. 생성자를 두 부분으로 쪼개야 하기 때문이죠. 하나는 자리를 만들고, 다른 하나는 그 자리에 제자리 초기화를 수행합니다. 개념적으로는 마지막 줄의 반환 타입 작성을 가변 인자에 쓰기로 바꿉니다.

//! 디슈가링

use std::mem::MaybeUninit;

impl Cat {
    /// 이 함수를 호출하면 타입을 초기화할
    /// 자리를 만듭니다. 이는 두 단계 생성자의
    /// 일부이며, 반드시 `new_init` 호출이
    /// 따라와야 합니다.
    unsafe fn new_uninit() -> Self {
        Self(MaybeUninit::uninit())
    }

    /// 타입의 값을 제자리로 초기화합니다.
    /// `new_init`은 한 번만 호출되어야 하며,
    /// 반드시 `new_uninit` 다음에 호출되어야 합니다.
    unsafe fn new_init(&mut self, age: u8) {
        let this = self.0.as_mut_ptr();
        unsafe { (&raw mut (*this).age).write(age) };
    }
}

다음으로 게터 함수도 있습니다. 여기서는 바깥 구조체를 통과해 내부 필드에 접근하는 법만 가르쳐 주면 됩니다. 정의는 이렇습니다:

//! 원본

#[placing]
impl Cat {
    fn age(&self) -> u8 {
        &self.age
    }
}

확장 결과는 이렇습니다:

//! 디슈가링

impl Cat {
    fn age(&self) -> u8 {
        let this = unsafe { self.0.assume_init_ref() };
        this.age
    }
}

이제 Cat 인스턴스를 제자리로 만들고, 게터를 호출할 준비가 되었습니다. 이 부분만은 추상화할 수 없습니다. 러스트 매크로는 컴파일러에 직접 구현했을 때와 달리 매우 엄격한 스코프 규칙을 갖기 때문입니다. 이상적으로는 다음과 같이 하고 싶습니다:

let cat = placing!(Cat, new, 12);

하지만 당장은 수동으로 호출해야 하므로, 호출부는 다음과 같습니다:

//! 원본

fn main() {
    let mut cat = unsafe { Cat::new_uninit() };
    unsafe { cat.new_init(12) };
    assert_eq!(cat.age(), &12);
}

여담으로: 러스트에는 현재 실험적인 super_let 기능이 있어, 둘러싼 스코프에서 타입을 생성하고 이후에 참조할 수 있습니다. 이는 우리의 용례에 거의 맞지만, 소유권 있는 값을 반환하는 대신 참조만 반환할 수 있다는 제약이 있습니다. 즉, 최선은 다음과 같습니다(playground):

#![feature(super_let)]

macro_rules! new_cat {
    ($value:expr $(,)?) => {
        {
            super let mut cat = unsafe { Cat::new_uninit()) };
            unsafe { Cat::new_init(&mut cat, $value) };
            &mut cat // ← ❌ 소유권 반환이 아니라 참조를 반환
        }
    }
}

소유값을 반환하려 하면 실제로 복사해 버립니다 — 우리가 피하려는 바로 그것이죠. 컴파일러 구현에서 이를 바꿀 수 있을지도 모르겠습니다. 요약하자면: 다음은 원래 예제의 placing 크레이트 버전입니다:

//! 원본

use placing::placing;

#[placing]
struct Cat {
    age: u8,
}

#[placing]
impl Cat {
    #[placing]
    fn new(age: u8) -> Self {
        Self { age }
    }

    fn age(&self) -> u8 {
        &self.age
    }
}

fn main() {
    // `Cat`이 제자리로 생성됩니다.
    let mut cat = unsafe { Cat::new_uninit() };
    unsafe { cat.new_init() };
 
    assert_eq!(cat.age(), &12);
}

그리고 모든 매크로를 확장한 동일한 코드입니다:

//! 디슈가링

use std::mem::MaybeUninit;

#[repr(transparent)]
struct Cat(MaybeUninit<InnerCat>);
struct InnerCat {
    age: u8,
}

impl Cat {
    /// 초기화되지 않은 자리를 만듭니다
    unsafe fn new_uninit() -> Self {
        Self(MaybeUninit::uninit())
    }

    /// 필드를 제자리로 초기화합니다
    fn new_init(&mut self, age: u8) {
        let this = self.0.as_mut_ptr();
        unsafe { (&raw mut (*this).age).write(age) };
    }

    fn age(&self) -> u8 {
        let this = unsafe { self.0.assume_init_ref() };
        this.age
    }
}

impl Drop for Cat {
    fn drop(&mut self) {
        unsafe { self.0.assume_init_drop() }
    }
}

fn main() {
    let mut cat = unsafe { Cat::new_uninit() };
    unsafe { cat.new_init() };
    assert_eq!(cat.age(), &12);
}

placing 크레이트는 Result, Box, Arc를 반환하는 타입의 디슈가링도 지원합니다. 또한 생성자 중첩도 지원하여 #[placing] fn에서 또 다른 #[placing] fn을 호출하는 경우도 처리합니다. 그래서 저는 이 접근이 충분히 잘 작동하리라 꽤 자신합니다.

크레이트의 주된 한계는 아직 트레이트를 지원하지 않는다는 것입니다. 이를 추가하기 시작했지만 시간이 부족했습니다. 크레이트의 코드 생성에서 어디에나 PLACING: bool const 제네릭이 삽입된 걸 본다면 그 이유입니다. 아직 딱히 쓸모 있지는 않지만, 이제 왜 있는지 아시겠죠.

플레이싱 함수로 사고하기

플레이싱 함수는 두 언어 기능에서 영감을 얻었습니다:

  • Super Let(Rust Nightly): 임시 수명 확장을 가능하게 하는 실험 기능입니다. 이를 통해 변수들을 둘러싼 스코프에서 생성할 수 있습니다.
  • Guaranteed Copy Elision(C++17): 때로는 “지연된 임시 물질화”라고도 하며, 구조체가 항상 호출자의 스코프에서 생성됨을 보장합니다.

super let이 블록 스코프에서 동작한다면, 플레이싱 함수는 함수 경계를 가로질러 동작한다고 볼 수 있습니다. 그리고 보장된 복사 생략이 모든 함수에 자동으로 적용되는 보장이라면, 플레이싱 함수는 명시적으로 이 기능에 옵트인한 함수에만 적용됩니다.

플레이싱 함수의 설계는 세 가지 핵심 제약을 균형 있게 고려합니다:

  • 제어(Control): 일부 경우 제자리 배치는 최적화(예: 인라이닝)를 통해 이미 발생합니다. 제자리 배치 언어 기능은 “반드시” 배치를 보장하거나, 그렇지 않으면 컴파일을 실패시켜야 합니다.
  • 통합(Integration): C++의 보장된 복사 생략은 제자리 배치의 적용 범위가 얼마나 넓은지 보여 줍니다. 이는 이 기능의 초기 상한선이 “반환 타입이 있는 모든 함수”라는 뜻입니다. 엄청나게 넓으므로, 언어의 나머지와 긴밀하고 쉽게 통합되도록 해야 합니다.
  • 호환성(Compatibility): Rust의 표준 라이브러리는 강한 하위 호환성을 보장합니다. 우리는 제자리 배치를 기존 API를 깨뜨리지 않고 활용하고 싶습니다. 즉, 배치를 지원하려고 새 트레이트를 만들거나, 배치만을 위한 새 API를 추가할 수는 없습니다.

“제자리 배치가 적용 범위가 좁은 기능”이라고 생각하는 것은 실수일 것입니다. C++은 거의 모든 생성자에서 배치가 관련 있음을 보여 줍니다. 제자리 배치를 생각하는 올바른 방법은, 가능한 한 넓은 상한을 기본값으로 상정하되, 최소 부분집합부터 설계하고 구현을 시작하는 것입니다. 언젠가 한계나 불가능한 경우를 찾게 될 수도 있지만, 그것은 구현을 통해 증명해 나갈 문제입니다.

그래서 저는 “제자리 배치”를 다른 종류의 언어 기능이 아니라, 일종의 효과(effect)에 가깝게 모델링해야 한다고 믿습니다. 플레이싱 함수는 const 함수와 async 함수의 중간쯤에 있다고 생각합니다. 이들은 asyncgen을 위해 사용하는 제너레이터 변환과 유사하게 함수의 코드 생성을 바꿉니다. 하지만 타입 시스템에서 관찰 가능한 다른 타입으로 낮추지는 않으므로 const와 매우 비슷합니다.

Rust의 선행 사례

이 글을 게시하기 직전, 저는 Sy Brand와 플레이싱 함수, C++, ABI에 대해 조금 더 대화를 나눴습니다. 알고 보니: 러스트는 이미 여러 경우에 제자리 배치를 보장하고 있습니다. 예를 들어 다음 코드를 보세요:

pub struct A {
    a: i64,
    b: i64,
    c: i64,
    d: i64,
    e: i64,
}

impl A {
    pub fn new() -> A {
        A {
            a: 42,
            b: 69,
            c: 4269,
            d: 6942,
            e: 696942,
        }
    }
}

이를 x86의 SYSV ABI로 컴파일하면 다음 어셈블리가 출력됩니다(godbolt):

example::A::new::hd00831bc57a4b613:
    mov rax, rdi
    mov qword ptr [rdi], 42
    mov qword ptr [rdi + 8], 69
    mov qword ptr [rdi + 16], 4269
    mov qword ptr [rdi + 24], 6942
    mov qword ptr [rdi + 32], 696942
    ret

이 어셈블리는 함수에 제공된 포인터 오프셋에 직접 씁니다. 즉: 이 함수는 배치를 수행합니다. 그리고 이는 x86 SYSV ABI 명세 3.2.3절에 정의된 대로 실제로 보장됩니다:

[!quote] If the type has class MEMORY, then the caller provides space for the return value and passes the address of this storage in %rdi as if it were the first argument to the function. In effect, this address becomes a “hidden” first argument This storage must not overlap any data visible to the callee through other names than this argument. On return %rax will contain the address that has been passed in by the caller in %rdi.

함수에 전달되는 숨겨진 첫 번째 인자라니요? 이건 플레이싱 함수의 디슈가링이 의도한 바와 꽤 비슷하게 들립니다. 실제로 C++의 보장된 복사 생략도 바로 이 기능을 사용합니다. 3.2.3절에는 다음과 같이 쓰여 있습니다:

If a C++ object has either a non-trivial copy constructor or a non-trivial destructor 11, it is passed by invisible reference (the object is replaced in the parameter list by a pointer that has class INTEGER) 12.

이는 제가 제안하는 것과 놀랄 만큼 비슷하지만, 언어 차원에서 투명하게가 아니라 ABI 차원에서 자동으로 일어납니다. 또한 이런 질문을 하게 만듭니다: rustc의 x64 ABI 로워링 코드를 수정해 “placing 애트리뷰트가 선언된 타입은 항상 MEMORY로 분류한다”라고 말하는 것이 얼마나 쉬울까요?

Q&A

플레이싱 인자(인수)는 어떡하죠?

지금까지는 반환 타입과 관련한 플레이싱만 논했습니다. 기존 표준 라이브러리와의 표현적 호환성을 유지하려면, 반환 타입만으로는 충분하지 않습니다. 3월에 Eric Holk와 Tyler Mandry는 어떤 형태로든 플레이싱 “인자”도 필요하다고(혹은 최소한 그것을 가능하게 하는 역량이 필요하다고) 주장했습니다. 왜 그런지 Box::new를 예시로 살펴보죠. 플레이싱 인자가 없다면, 최선은 플레이싱 클로저를 받는 Box::new_with 같은 것을 정의하는 것입니다:

impl<T> Box<T> {
    // 기존 기본 생성자
    fn new(x: T) -> Self { ... }

    // 새로 도입된 `placing` 생성자
    fn new_with<F>(f: F) -> Self
    where
        F: #[placing] FnOnce() -> T,
    { ... }
}

new_with 생성자는 중간 스택 복사의 부재를 보장하므로 언제나 new보다 바람직합니다. 이는 사실상 Box::new의 폐기를 초래합니다. 명시적으로 폐기하지 않더라도, 아마 먼저 “권장 관행”을 통해서 그렇게 되겠죠.

이를 해결하는 방법은, Box::new가 제자리 배치가 필요한 값들의 “수신자”로 동작할 수 있게 하는 것입니다. 이는 함수 레벨이 아니라 인자/반환 타입 레벨에서 애너테이션을 요구함으로써 이루어질 수 있습니다. 우리의 임시 표기인 #[placing]을 계속 사용한다면, 아래처럼 생겼을 겁니다:

//! 원본

impl<T> Box<T> {
    // 여기서 `Box::new`는 타입 `T`를 받아
    // 힙에 제자리로 구성합니다
    fn new(x: #[placing] T) -> Self { ... }
}

이 디슈가링은 아마 Alice Ryhl의 제자리 초기화 RFC처럼 어떤 형태의 impl Emplace 트레이트로 풀릴 겁니다. 하지만 결정적으로: 이는 구현체 내부에서만 관찰 가능하며, 호출자들에게는 보이지 않습니다.

//! 디슈가링

/// 자리에 값을 씁니다.
trait Emplace<T> {
    fn emplace(self, slot: *mut T);
}

impl<T> Box<T> {
    fn new(x: impl Emplace<T>) -> Self {
        let mut this = Box::<T>::new_uninit(); // 1. 자리를 생성
        x.emplace(this.as_mut_ptr());          // 2. 값을 초기화
        Ok(this.assume_init())                 // 3. 완료
    }
}

여기 Q&A에 둔 이유는 아직 이를 구현하지 않았기 때문입니다. 그래서 세부 언어 규칙을 다 정리하지는 못했습니다. 핵심 설계 제약은 이렇습니다: 플레이싱 인자를 받는 함수를 호출하는 것은 일반 함수를 호출하는 것과 “아무런 차이”가 없어야 합니다. 이는 API의 하위 호환을 유지하는 데 필요합니다. 이 기능의 특별함은 플레이싱 인자와 플레이싱 반환 타입을 가진 함수들이 협력하여 제자리 배치를 이룰 수 있어야 한다는 점입니다.

대여/로컬 수명 확장은요?

super let에 관한 글에서 Mara는 임시 수명 확장이 유용한 상황을 명확히 보여 줍니다. 여기서 Writer::new&'a File을 받고, 우리가 super let 같은 기능이 필요한 이유는 블록 스코프를 벗어나 살아남는 File 인스턴스를 만들기 위해서입니다:

let writer = {
    println!("opening file...");
    let filename = "hello.txt";
    super let file = File::create(filename).unwrap();
    Writer::new(&file)
};

여기서 반환 타입 Writersuper let file을 참조할 수 있도록 수명이 필요합니다. 하지만 보통의 규칙을 따르지 않기에 일반 수명이 될 수는 없습니다. 구체 규칙을 명시하지는 않았지만, 이 수명은 'super로 불립니다. 블록의 관점에서는 'static과 비슷하게 동작하지만, 'static과 동일한 것은 아닙니다.

그렇다면 이 블록을 함수로는 어떻게 표현할 수 있을까요? 결국에는 블록에서 기능을 뽑아 함수로 만들어 내고 싶을 겁니다. 아마 'super 수명을 사용해 다음처럼 하고 싶을 것입니다:

//! 원본

fn create_writer(filename: &str) -> Writer<'super> {
    println!("opening file...");
    super let file = File::create(filename).unwrap();
    Writer::new(&file)
}

여기서 Writer 자체는 #[placing] 애너테이션이 필요 없습니다. 함수 밖으로 복사해 내도 괜찮기 때문입니다. 중요한 점은 file이 현재 스코프보다 오래 살아야 한다는 것입니다. 타입 시스템으로는 아직 표현할 수 없더라도 디슈가링은 꽤 흥미롭습니다. 여기서 해야 할 일은 file이 호출자 스코프에 제자리로 생성되도록 보장하는 것입니다. 초기화가 끝나면, 반환 타입에서 빈/unsafe 수명으로 이를 참조할 수 있습니다. 실제로 확인해 보지는 않았지만, 유효한 디슈가링은 아마 다음과 같을 것입니다:

//! 디슈가링

fn create_writer<'a>(filename: &str, file: &'a mut MaybeUninit<File>) -> Writer<'a> {
    println!("opening file...");
    let file = unsafe { file.write(File::create(filename).unwrap()) };
    Writer::new(file)
}

다만 호출 시에는 file을 위한 자리를 만들기 위해 함수 프렐류드가 짝지어져야 합니다. 따라서 이 함수의 호출은 다음처럼 생겼다고 상상할 수 있습니다:

// 문법 설탕 버전
let writer = create_writer("hello.text");

// 디슈가링 버전
let mut file = MaybeUninit::uninit();
let writer = create_writer("hello.text", &mut file);

Pinning은요?

언어에 제자리 배치가 도입되면, Pin이 현재 모양을 하고 있어야 할 대부분의 이유가 사라집니다. 하지만 제자리 배치가 있다고 해도 !Move가 바로 따라오는 것은 아니기에, Pin과의 호환성은 필요합니다. 다행히 Pin 타입은 방금 전 수명 확장 예시의 특수한 경우입니다.

플레이싱 함수가 멋진 점은, 'super 수명을 사용해 std::pin::pin! 매크로를 pin 프리 함수로 대체할 수 있게 해 준다는 것입니다:

//! 원본

pub fn pin<T>(t: T) -> Pin<&'super mut T> {
    super let mut t = t;
    unsafe { Pin::new_uninit(&mut t) }
}

이를 가능케 하는 디슈가링은 함수가 값을 쓸 수 있는 추가 슬롯 MaybeUninit<T>를 인자로 받도록 바꾸는 것입니다. 이렇게 하면 수명을 확장할 수 있고, 이후 반환 타입에서 다음처럼 참조할 수 있습니다:

//! 디슈가링

pub fn pin<T, 'a>(t: T, slot: &'a mut MaybeUninit<T>) -> Pin<&'a mut T> {
    let mut t = unsafe { slot.write(t) };
    unsafe { Pin::new_uninit(&mut t) }
}

애너테이션이 꼭 필요할까요?

RFC 2884: Placement by Return은 C++의 보장된 복사 생략 규칙을 거의 그대로 러스트에 도입할 것을 제안했습니다. 이 RFC가 마음에 드는 점은 변경 범위를 올바르게 파악했고, return의 의미만 바꾸는 선에서 해결하려 했다는 것입니다. 하지만 문제는 “오직” return의 의미만 바꾼다는 점입니다. 그래서 예컨대 Box::new에 배치를 추가하는 대신, 새 메서드 Box::new_with를 추가해야 했습니다.

근본적으로 우리가 관심 있는 배치에는 세 종류가 있습니다:

  • 플레이싱 반환 타입: 함수가 반환하는 타입의 복사를 피하고 싶을 때. 예: 참조 안정적 생성자를 원할 때.
  • 플레이싱 함수 인자: 함수로 전달되는 인자의 복사를 피하고 싶을 때. 예: 힙에 타입을 구성할 때.
  • 수명 확장: 현 스코프를 넘어 살아남는 로컬 타입에서 로컬 변수를 참조하고 싶을 때(수명 확장). 예: pinning.

C++의 보장된 복사 생략이 궁극적 목표가 될 수 있더라도, 애너테이션은 점진적으로 그 목표에 도달하게 해 줍니다. 플레이싱 반환 타입은 비교적 쉽습니다. 플레이싱 함수 인자는 조금 더 어렵습니다. 수명 확장은 더더욱 어렵습니다. 명시적 애너테이션으로 옵트인할 수 있다면, 작게 시작해 점차 확장해 갈 수 있습니다.

“인자나 반환 타입이 있는 모든 함수”같이 범위가 넓은 기능에는 이런 접근이 옳아 보입니다. 그리고 만약 이 기능이 매우 성공적이라 거의 모든 함수에 이를 붙이고 싶어질 정도가 된다면, 원한다면 에디션을 통해 기본값을 바꾸는 것도 가능할 것입니다.

자기 참조 타입은요?

저는 이전에 자기 참조 타입에 대해 길게 쓴 바 있습니다. 플레이싱 함수가 생기면, 일반화된 자기 참조 타입을 위한 세 가지 구성 요소가 갖춰집니다:

  1. 플레이싱 함수: 메모리의 안정적 위치에 타입을 구성하기 위해
  2. self 수명(Self-lifetime): 어떤 필드가 다른 필드로부터 빌린다는 것을 선언하기 위해
  3. 부분 생성자: 먼저 소유 데이터를 초기화하고, 그 다음 그 데이터에 대한 참조를 초기화하기 위해

플레이싱 함수는 이미 보았습니다. self 수명은 필드가 다른 필드의 데이터를 참조하게 해 주며, 아마 다음처럼 보일 것입니다:

struct Cat {
    data: String,
    name: &'self.data str, // ← `self.data`를 참조합니다
}

그리고 부분 생성자는 여러 단계로 타입을 구성할 수 있게 합니다. 예를 들어, 먼저 Catdata 필드를 초기화한 다음, 그 안의 문자열을 가져와 첫 공백까지를 고양이 이름으로 간단히 파싱합니다:

fn new_cat(data: String) -> Cat {
    let mut cat = Cat { data, .. };
    cat.name = cat.data.split(' ').next().unwrap();
    cat
}

여기까지 갖추면, 앞서 본 수명 확장 예시와 결합해 타입이 안정적 메모리 위치를 유지하게 할 수 있습니다. 중요한 점은: 이는 Move 오토 트레이트 같은 다른 참조 안정성 메커니즘과도 전방 호환된다는 것입니다.

사족으로: 부분 생성자는 사실 뷰 타입과 패턴 타입과 거의 같은 기능입니다. 여전히 같은 일반적 정제(refinement) 기능인데, 이제는 필드를 채워 원래 타입으로 되돌아갈 수 있다는 규칙이 추가되었을 뿐입니다. 할당이 일종의 역 match 역할을 한다고 볼 수 있습니다.

이 설계가 좋은 점은 이 기능들이 서로 직교하면서도 보완적이라는 것입니다. 제자리 배치는 부분 초기화가 없어도 유용합니다. 그리고 부분 초기화(정제)는 self 참조 수명이 없어도 유용합니다. 이런 식으로 서로를 보완하는 기능은 좋은 언어 설계의 특징이라고 생각합니다. 틈새 용례를 넘어 일반화되기 때문입니다. 동시에 다른 기능과 결합될 때 더 유용해집니다.

Init<T>&out에 바로 의존하면 안 되나요?

Init 타입과 &out 파라미터 기능은 기존 타입과 인터페이스에 하위 호환적으로 추가하기 어렵습니다. 이는 문제입니다. 왜냐하면 배치는 적용 범위가 넓기 때문입니다. C++17에서 보듯 사실상 거의 모든 생성자가 플레이싱을 원합니다. 그리고 -> T를 반환하는 모든 함수를 -> Init<T>로 바꾸거나 &out T를 받도록 다시 쓸 수는 없습니다.

// 1. 원래 시그니처
fn new_cat() -> Cat { ... }

// 2. `Init` 사용: 시그니처가 바뀜
fn new_cat() -> Init<Cat> { ... }

// 3. `&out` 사용: 시그니처가 바뀜
fn new_cat(cat: &out Cat) { ... }
 
// 3. `#[placing]` 사용: 시그니처가 그대로임
#[placing]
fn new_cat() -> Cat { ... }

이것이 위 설계들이 본질적으로 잘못되었다는 뜻은 아닙니다. 전혀요. 다만 다른 범위를 상정하는 듯 보이기 때문에, 자연스레 다른 설계 제약으로 작업하게 되고, 그 결과 다른 설계가 나온다는 뜻입니다.

저는 PoignardAzur의 RFC 2884: Placement by Return이 올바른 생각을 담고 있었다고 믿습니다. 러스트가 C++과 경쟁력 있는 성능을 내려면, 대부분의 생성자가 제자리 배치를 보장할 수 있어야 합니다. 그리고 이를 위해 코드를 다시 쓰도록 요구해서는 안 됩니다.

하지만 Init RFC는 함수 인자를 어떻게 제자리 배치할지에 대한 훌륭한 아이디어들을 담고 있습니다. 이는 RFC 2884가 잘 답하지 못했던 부분입니다. 러스트의 강점은 서로 다른 아이디어를 증류해 새것으로 합성하는 데 있다고 믿습니다. super let, 반환에 의한 배치, Init을 결합하고, 하위 호환을 보장한다면 정말 훌륭한 지점에 도달할 수 있다고 봅니다.

플레이싱 함수를 중첩할 수 있나요?

네 — 반환 위치에서 호출되는 플레이싱 함수는 합성될 수 있어야 합니다. 반환 위치에서 하나의 플레이싱 함수 안에서 다른 플레이싱 함수를 호출하는 경우, 언어 기능 차원에서는 비교적 직선적입니다:

struct Foo {}
#[placing]
fn inner() -> Foo {
    Foo {} // ← 1. 호출자 스코프에 생성
}

#[placing]
fn outer() -> Foo {
    inner() // ← 2. 배치를 호출자에게 전달
}

이는 C++의 보장된 복사 생략 보장과도 닮아 있습니다. C++에서는 임시객체가 호출 체인의 끝에서야 실제 객체로 물질화되기 때문에, 임의로 깊게 합성할 수 있습니다. 러스트에서도 이 성질을 유지하는 것이 중요합니다. 래핑과 합성에서도 포함해서요:

struct Foo {}
#[placing]
fn inner() -> Foo {
    Foo {} // ← 1. 호출자 스코프에 생성
}

struct Bar(Foo)
#[placing]
fn outer() -> Bar {
    Bar(inner()) // ← 2. Bar는 호출자에 배치, Foo는 Bar 안에 배치
}

이 예시에서는 Bar가 호출자 스코프에 생성되고, 초기화의 일부로 Foo를 호출하고 그 안에 배치합니다. 저는 placing 크레이트 작업을 중단하기 전에 이 부분을 구현하지는 못했지만, 이 디슈가링이 작동하려면 inner 함수가 사용하는 “자리”를 outer 함수의 “자리” 안에 배치하기만 하면 됩니다.

가장 복잡한 합성은 임시값이 끼어드는 경우입니다. 다음 예시는 첫 번째 함수에서 타입을 구성하고, 두 번째 함수에서 이를 변경한 뒤, 세 번째 함수에서 사용하는 경우입니다:

/// 제자리로 생성되고 야옹할 수 있는 Cat.
struct Cat {}
impl Cat {
    #[placing]
    fn new(name: String) -> Self { .. }
    fn set_name(&mut self) { .. }
    fn meow(&self) { .. }
}

/// 값을 구성합니다.
#[placing]
fn first() -> Cat {
    let name = "Nori".to_string();
    Cat::new(name) // ← 1. 호출자에 배치
}

/// 값을 변경합니다.
#[placing]
fn second() -> Cat {
    super let mut cat = first();        // ← 2. 호출자에 배치
    cat.set_name("Chashu".to_string()); // ← 3. 변경
    cat                                 // ← 4. 논리적으로 반환
}

/// 값을 사용합니다.
fn third() {
    let cat = second(); // ← 5. 여기 스택에 배치
    cat.meow();
}

나중에 논리적으로 반환할 임시값을 제자리 배치할 수 있는 방법이 있어야 합니다. super let 기능은 여기에 특히 잘 맞아 보입니다. 'move 예시에서는 함수에서 super let 값에 대한 참조를 반환했습니다. 하지만 여기서 보듯 소유값을 반환하기 위해서도 super let을 사용할 수 있으면 좋겠습니다.

결론

이 글은 플레이싱 함수라는 설계를 소개합니다. 러스트에 선언적으로 추가되어, 타입이 제자리로 생성되도록 합니다. Init<T>&out 같은 대체 API와 달리, 플레이싱 함수는 함수 시그니처를 그대로 유지하도록 설계되어 기존 함수와 API에 소급 적용할 수 있습니다. 다시 말해: 플레이싱 함수는 하위 호환성을 우선시하도록 설계되었습니다.

플레이싱 기능은 placing 크레이트로 프로토타이핑되었습니다. 이 크레이트는 제한된 시간 안에 전적으로 프로시저 매크로로 설계되었지만, 언어 기능으로서의 실현 가능성을 입증하기에는 충분합니다.

플레이싱 함수가 배치 관련 기능 중 가장 중요하지만, 유일한 것은 아닙니다. 총 세 종류의 플레이싱이 다뤄져야 합니다:

  1. 플레이싱 반환 타입: 함수가 반환하는 타입의 복사를 피하고 싶을 때. 예: 참조 안정적 생성자.
  2. 수명 확장: 현 스코프를 넘어 살아남는 로컬 타입에서 로컬 변수를 참조하고 싶을 때. 예: pinning.
  3. 플레이싱 함수 인자: 함수로 전달되는 인자의 복사를 피하고 싶을 때. 예: 힙에서 타입을 구성할 때.

완전한 해법은 이 세 가지 모두를 다뤄야 합니다. 플레이싱 함수는 직접적으로는 반환 타입의 플레이싱만 가능케 하지만, 본문에서 수명 확장과 인자 플레이싱으로 확장하는 방법도 함께 논의했습니다.

제자리 배치를 논할 때는 함수 내부(인트라-펑션)와 함수 간(인터-펑션) 두 가지를 모두 고려하는 것이 중요합니다. 실험적인 super let 기능은 오늘날 함수 “내부”에서만 동작합니다(인트라-펑션). 플레이싱 함수는 매우 비슷하게 동작하며, 이와 유사한 기능을 함수 “사이”에서도 동작하게 해 줍니다(인터-펑션). 좋은 설계는 두 변형 모두를 쉽고, 편리하고, 상호 운용 가능하게 만들어야 합니다.

또한 이 글에서는 제자리 배치의 범위가 넓은 이유도 설명했습니다. C++에서는 생성자가 기본적으로 배치를 보장합니다. 그리고 러스트가 C++과 경쟁력 있는 성능을 원한다면, 대부분의 함수 역시 궁극적으로 그 보장을 원하게 될 것이라고 가정해야 합니다. 이를 달성하는 유일하게 현실적인 방법은 하위 호환적으로 배치를 보장하는 것입니다.

Sy Brand가 글의 초기 사본을 검토해 주고, C++의 복사 생략 기능과 SYSV ABI 작동 방식에 대한 귀중한 피드백과 설명을 제공해 준 것에 감사드립니다.

주석

  1. 저는 그저 가능하겠다는 직감만 있었습니다. Alice Ryhl이 실제로 이걸 증명했습니다. 약간 다르지만 매우 유사한 제안에 대해요.

참고문헌

모든 참고 링크 보기

  1. https://blog.yoshuawuyts.com/placing-functions/#what-are-placing-functions
  2. https://blog.yoshuawuyts.com/placing-functions/#a-basic-desugaring
  3. https://blog.yoshuawuyts.com/placing-functions/#thinking-in-placing-functions
  4. https://blog.yoshuawuyts.com/placing-functions/#prior-art-in-rust
  5. https://blog.yoshuawuyts.com/placing-functions/#q-a
  6. https://blog.yoshuawuyts.com/placing-functions/#what-about-placing-arguments
  7. https://blog.yoshuawuyts.com/placing-functions/#what-about-borrows-local-lifetime-extensions
  8. https://blog.yoshuawuyts.com/placing-functions/#what-about-pinning
  9. https://blog.yoshuawuyts.com/placing-functions/#are-annotations-necessary
  10. https://blog.yoshuawuyts.com/placing-functions/#what-about-self-referential-types
  11. https://blog.yoshuawuyts.com/placing-functions/#why-not-directly-rely-on-init-t-or-out
  12. https://blog.yoshuawuyts.com/placing-functions/#can-placing-functions-be-nested
  13. https://blog.yoshuawuyts.com/placing-functions/#conclusion
  14. https://blog.yoshuawuyts.com/in-place-construction-seems-surprisingly-simple/
  15. https://github.com/yoshuawuyts/placing
  16. https://blog.yoshuawuyts.com/placing-functions/#1
  17. https://play.rust-lang.org/?version=nightly&mode=debug&edition=2024&gist=606a4613c99f3a76c189537c92b9e116
  18. https://blog.m-ou.se/super-let/
  19. https://devblogs.microsoft.com/cppblog/guaranteed-copy-elision-does-not-elide-copies/
  20. https://godbolt.org/z/MeY76Ys9z
  21. https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf
  22. https://hackmd.io/@aliceryhl/BJutRcPblx
  23. https://blog.m-ou.se/super-let/#super-let
  24. https://github.com/rust-lang/rfcs/pull/2884
  25. https://blog.yoshuawuyts.com/self-referential-types/
  26. https://blog.yoshuawuyts.com/syntactic-musings-on-view-types/
  27. https://github.com/rust-lang/rfcs/pull/2884
  28. https://hackmd.io/@aliceryhl/BJutRcPblx
  29. https://github.com/yoshuawuyts/placing