함수 항목과 함수 포인터, 클로저(Fn/FnMut/FnOnce)와 캡처 방식, 그리고 둘 사이의 변환 규칙을 예제와 내부 동작으로 쉽게 풀어 설명합니다.
Rust는 그만한 이유가 있어서 가파른 학습 곡선으로 유명합니다. 대여 검사기(borrow checker), 수명(lifetime) 같은 개념들 외에도, 함수와 클로저는 러스트 입문자에게 특히 혼란스러운 주제입니다. 예시를 보세요:
예제 1: 함수와 클로저의 혼란
process_data는 i32를 받아 i32를 반환하는 함수를 기대합니다. 두 클로저 모두 이 시그니처와 정확히 일치합니다. 첫 번째 클로저 |x| x + 1는 잘 동작하지만, 두 번째 클로저 |x| x * multiplier는 실패합니다. 무엇이 다를까요?
오류: error[E0308]: mismatched types (expected fn pointer, found closure)
예제 2: 변수 캡처의 혼란
두 클로저 모두 data를 사용하지만, 하나는 여러 번 호출해도 동작하는 반면 다른 하나는 첫 호출 이후로 동작하지 않습니다. 무엇이 다를까요?
오류: error[E0382]: use of moved value: 'closure2'
이런 상황을 겪어봤다면, 혼자가 아닙니다. 이 글에서는 다음을 다룹니다:
글을 다 읽고 나면 Rust에서 가장 어려운 주제를 완전히 이해하게 될 것입니다. 시작해봅시다.
먼저 Rust가 일반 함수를 어떻게 다루는지 살펴보겠습니다. 대부분의 개발자가 접하지 못하는, 함수 타입에 관한 놀라운 사실들이 있습니다.
Rust의 함수는 우리가 예상하는 그대로입니다:
충분히 간단하지요. 그런데 흥미로운 점이 있습니다. add_one 같은 함수 이름을 언급할 때, 우리가 받는 것은 그 함수에 대한 포인터가 아닙니다. 대신 해당 함수를 정확히 나타내는, 크기가 0인 특별한 값을 얻게 되며, 이를 호출하면 직접 호출이 일어납니다. 이 크기가 0인 값을 **함수 항목(function item)**이라고 합니다.
정의하는 모든 함수는 함수 항목이라 불리는, 고유한 크기 0의 타입을 만듭니다. 이 타입은 컴파일러가 생성하며 코드에서 직접 이름을 붙일 수 없습니다:
f1과 f2는 함수 시그니처가 동일하지만 서로 다른 함수 항목 타입을 가집니다. 예시 마지막에서 add_two를 f1에 대입하려고 하면, 그 때문에 타입 불일치 에러가 발생합니다.
이러한 고유한 타이핑 덕분에 강력한 컴파일러 최적화가 가능합니다. 컴파일러는 컴파일 타임에 정확히 어떤 함수가 호출될지 알 수 있으므로:
예를 들어, **f1(x)**는 호출 오버헤드를 완전히 제거하고 x + 1로 직접 대체됩니다.
그렇다면 서로 다른 함수를 같은 변수에 저장하려면 어떻게 할까요? 함수 포인터로 변환합니다:
이제 컴파일러에 f1과 f2가 fn(i32) -> i32 타입의 함수 포인터임을 명시합니다. Rust는 add_one과 add_two(함수 항목)를 함수 포인터로 강제 변환(coerce)하여 f1, f2에 대입합니다. 마지막에 보듯이 이전 예시와 달리 add_two는 물론 f2를 f1에 대입하는 것도 잘 됩니다.
대신 함수 포인터 호출은 포인터를 통한 간접 호출인 동적 디스패치를 사용하므로, 함수 항목에서의 직접 호출과 달리 일반적으로 인라인이 어려워집니다. 하지만 유연성을 얻습니다.
이제 클로저가 무엇이며 함수와 왜 다른지 이해해봅시다.
클로저는 주변 환경으로부터 변수를 캡처할 수 있는 익명 함수입니다:
핵심 차이점: 클로저는 자신을 둘러싼 스코프의 변수에 접근할 수 있지만, 일반 함수는 그럴 수 없습니다.
모든 클로저가 같은 방식으로 변수를 캡처하는 것은 아닙니다. 캡처 방식은 클로저가 그 변수를 어떻게 사용하는지에 따라 달라지며, 이는 곧 그 클로저가 구현하는 트레이트를 직접 결정합니다.
Rust 컴파일러는 캡처한 변수를 어떻게 사용하는지에 따라 각 클로저에 자동으로 세 가지 트레이트 중 하나를 부여합니다:
FnOnce - 캡처한 변수를 이동(move)합니다
한 번 data가 drop되면, 클로저를 다시 호출할 경우 같은 메모리를 두 번 drop하려 하게 되어 정의되지 않은 동작을 유발합니다. Rust는 이런 클로저를 한 번만 호출 가능하도록 만들어 이를 방지합니다.
FnMut - 캡처한 변수를 변경(mut)합니다
Fn - 캡처한 변수를 읽기만 합니다
move 키워드에 대한 참고: 가끔 move 키워드가 붙은 클로저를 보게 되는데, 이는 클로저가 캡처한 변수의 소유권을 가져가도록 강제합니다. 하지만 move가 항상 FnOnce를 의미하는 것은 아닙니다. 캡처된 변수가 drop되거나 소모되지 않는다면, 해당 클로저는 여러 번 호출될 수 있으며 FnOnce가 아닐 수 있습니다.
FnOnce 클로저를 받는 함수를 살펴봅시다:
이 함수는 클로저를 단 한 번만 호출하겠다고 약속합니다. 클로저에 부과하는 제약은 그것뿐입니다.
그렇다면 여러 번 호출 가능한 Fn 클로저를 execute_once에 넘길 수 있을까요? 물론입니다! 여러 번 호출 가능한 클로저는 한 번만 호출하는 것도 가능합니다. 같은 논리는 FnMut 클로저에도 적용됩니다. FnMut도 여러 번 호출될 수 있으니 한 번만 호출하는 것도 가능합니다.
이로써 위계가 생깁니다: Fn은 어디서든 쓸 수 있고, FnMut는 FnMut 또는 FnOnce가 필요한 곳에서 쓸 수 있으며, FnOnce는 FnOnce가 요구될 때만 쓸 수 있습니다.
좋습니다. 함수와 클로저에 관해 기억해야 할 규칙이 꽤 많네요. 규칙을 외우는 것도 좋지만, 금방 잊어버리기 마련입니다. 그래서 내부에서 무슨 일이 일어나는지 이해하는 편이 훨씬 낫습니다. 이제 Rust가 클로저를 실제로 어떻게 구현하는지 파고들어 봅시다.
읽어주셔서 감사합니다! 이 글은 공개되어 있으니 자유롭게 공유하세요.
클로저를 작성하면, Rust 컴파일러는 내부적으로 이를 트레이트 구현이 붙은 컴파일러 생성 익명 구조체로 변환합니다:
컴파일러는 개념적으로 이 클로저를 다음과 같이 변환합니다:
이 변환에서 원래의 data 변수는 ClosureEnvironment 구조체로 이동(move)됩니다. 클로저가 호출되면 자기 자신(self)을 소비(consumes)하고, 이동된 데이터를 drop합니다. 이것이 클로저를 한 번만 호출할 수 있는 이유입니다. 첫 호출 후에는 클로저 구조체와 캡처된 데이터가 모두 소비됩니다.
같은 개념이 Fn과 FnMut에도 적용됩니다. Rust는 모든 클로저를, 캡처된 변수를 어떻게 사용하는지에 따라 적절한 트레이트 구현을 갖춘 컴파일러 생성 구조체로 변환합니다.
컴파일러는 가능한 한 덜 침습적인 캡처 모드를 선택합니다. 각 캡처 변수에 대해 가장 관대한 옵션(불변 대여)에서 시작하여, 클로저 본문이 요구할 때만 더 제한적인 모드(가변 대여, 그 다음 이동)로 격상합니다.
앞에서 FnOnce가 기대되는 곳에 Fn 클로저를 전달할 수 있다고 했는데, 서로 다른 트레이트인데도 어떻게 가능할까요? 답은 트레이트 정의 방식에 있습니다.
세 가지 클로저 트레이트는 트레이트 상속을 통해 위계를 형성합니다:
**: FnOnce<Args>**와 : FnMut<Args> 문법에 주목하세요. 이는 다음을 의미합니다:
앞서 예제 1에서 보았듯이, 어떤 때는 함수 포인터를 기대하는 함수에 클로저를 전달할 수 있고, 어떤 때는 불가능합니다. 그 이유를 이해해봅시다.
클로저가 어떤 변수도 캡처하지 않으면, 앞서 본 숨겨진 구조체가 필요 없습니다. 저장할 캡처 상태가 없기 때문에, Rust는 그런 클로저를 평범한 함수 포인터로 곧바로 강제 변환할 수 있습니다:
하지만 클로저가 변수를 캡처하는 순간, 이런 강제 변환은 더 이상 불가능합니다:
참고: 추가 제약(예: 클로저가 non-async여야 함)도 있지만 여기서는 다루지 않겠습니다.
함수는 본질적으로 캡처하지 않는 클로저와 같아서, 상태가 없고 부작용 없이 반복 호출할 수 있습니다. 따라서 함수 포인터는 세 가지 클로저 트레이트(Fn, FnMut, FnOnce)를 모두 구현합니다:
이 양방향 호환성(캡처하지 않는 클로저는 함수 포인터가 될 수 있고, 함수 포인터는 클로저처럼 사용할 수 있음)은 Rust의 함수와 클로저 시스템 사이에 매끄러운 통합을 만들어냅니다.
읽어주셔서 감사합니다! 이 글은 공개되어 있으니 자유롭게 공유하세요.
Rust의 함수와 클로저 시스템에 대해 배운 내용을 정리해봅시다:
여기까지 읽으셨다면, 저처럼 사물이 실제로 어떻게 동작하는지 이해하는 데 집착하는 분일 겁니다. 저는 Cuong이고, Rust와 프로그래밍에 대해 글을 씁니다. 같은 열정을 공유하신다면 꼭 연결하고 싶습니다. X, LinkedIn에서 연락 주시거나 제 블로그(substack, medium)를 구독해 함께 한계를 넓혀가요!