본문 바로가기

러스트의 함수와 클로저

기술적인 이야기/기타 개발 2020. 8. 10.
반응형

함수는 반복되는 명령을 별도로 분리해서 쉽게 호출할 수 있게 해 주기 때문에 모든 프로그래밍 언어에서 아주 중요한 기능입니다. 러스트도 당연히 이 함수 기능이 제공되지요. 이 함수 기능을 살펴보고, 하는 김에 함수와 비슷한 클로저도 살펴보기로 했습니다.

함수(Functions)

이미 main 함수에서도 봐왔지만 함수는 fn이라는 명령으로 정의할 수 있습니다.

fn hello() {
    println!("Hello?");
}

fn main() {
    hello();
}

설명할 필요도 없을 만큼 간단한 예제이지만 간략히 설명하자면 "Hello?"라는 메시지를 출력하는 hello라는 이름의 함수를 만들고 이를 main 함수에서 호출하고 있는 예제입니다. 이제 저 "Hello?" 메시지를 찍고 싶다면 hello 함수만 호출하면 되지요.

이런 식으로 함수는 반복해서 사용될 만한 기능을 분리해서 구현하고 쉽게 호출하기 위한 목적으로 사용합니다. 반복되는 코드를 없애주기 때문에 코드 양도 줄어들고, 함수 이름을 통해 기능을 유추하는 등 코드 가독성에서도 중요한 역할을 하기도 합니다.

참고로 위의 hello 함수에서 쓰인 println!()는 함수가 아니라 매크로(Macros)입니다. 러스트에서 특정 심벌 뒤에 느낌표가 붙은 것은 매크로인데 함수와는 다르므로 일단 구분은 해둡시다.

매개변수(Parameters)

위의 hello 함수는 아무런 매개변수 없이 메시지만 출력하는 단순한 기능을 가졌는데, 대부분의 함수는 매개변수를 통해 일을 하기 위해 데이터를 입력받습니다. 물론 러스트의 함수도 동일합니다. 아래 예제는 위에서 구현한 hello 함수에 매개변수로 이름과 출력 개수를 알릴 수 있도록 수정한 것입니다.

fn hello(name: &str, count: i32) {
    for _i in 0..count {
        println!("Hello {}?", name);
    }
}

fn main() {
    hello("Rust", 10);
}

이제 hello() 함수는 이름과 출력할 개수를 매개변수(Parameters)로 입력받을 수 있습니다. main 함수에서 이를 어떻게 호출하는지 볼 수 있습니다.

hello() 함수에서 i 대신 _i라는 이름을 쓴 이유는 사용하지 않는 변수라는 컴파일 경고를 숨기기 위한 것일 뿐입니다.

지금까지는 반환 값이 없는 함수를 봤습니다. 보통 이런 반환이 없는 함수를 프로시져(Procedure)라고 부르기도 하는데 뭐 꼭 이렇게 불러야 한다는 법은 없으니 마음대로 불러도 될 것 같습니다.

반환 타입(Return Types)

프로시져 개념과 다르게 보통 함수(function)라 부르는 것은 반환 값이 있는 존재를 의미한다고 생각할 수 있습니다. 역시 대부분의 언어에서 함수는 반환 타입(Return Types)이라는 것이 존재하고 이는 러스트에서도 마찬가지입니다.

fn add(l: i32, r: i32) -> i32 {
    return l + r;
}

화살표처럼 생긴 -> 오퍼레이터 뒤에 표시된 타입이 바로 반환 타입입니다. 즉 위의 add() 함수는 32비트 정수를 반환하는 함수입니다. 다른 언어들처럼 return 명령을 이용해 값을 반환하는 것도 매우 흔한(?) 언어 같습니다.

식(Expression)

위에서 구현한 add 함수 코드는 아래처럼 축약이 가능합니다.

fn add(l: i32, r: i32) -> i32 {
    l + r
}

러스트의 특수한 문법으로 return과 줄 끝에 세미콜론을 빼버렸는데 이 앞의 코드와 동일한 동작을 합니다. 이런 코드를 러스트에서는 식(expression)이라고 표현합니다. 코드 양을 줄일 때 많이 활용하니 잘 알아두면 좋을 것 같습니다.

함수 포인터(Function Pointer)

함수는 함수 포인터라는 방식을 통해 특정 변수에 대입하는 것이 가능합니다.

fn add(l: i32, r: i32) -> i32 {
    l + r
}

fn main() {
    let addition: fn(i32, i32) -> i32 = add;
    println!("{}", addition(1, 2));
}

addition이라는 변수의 타입을 잘 살펴보면 add 함수의 원형에서 함수 이름만 빠진 형태라는 것을 알 수 있습니다. 그리고 이 변수에 add() 함수를 바로 대입했고, 결국 additionadd() 함수를 가리키는 함수 포인터가 됩니다. 그리고 이를 그대로 함수처럼 실행시킬 수 있습니다.

함수에 관한 기본적인 내용은 이 정도면 되지 않을까 생각되네요. 그럼 이제 클로저로 넘어가 봅시다.

 

클로저(Closures)

클로저의 개념은 여러 방식으로 설명하곤 합니다. 익명 함수라거나 동적 생성 함수라거나, 납치 전문가(?) 등등 말이죠. 일단 여기서는 간단히 문법만 살펴보고자 합니다.

러스트의 클로저는 대충 아래와 같은 문법으로 선언합니다.

|parameters| -> return_type { body };

수직 막대기 형태 내부에 매개변수 선언을 하고 나머지는 함수와 비슷한 모양새입니다. 그리고 함수와는 다르게 이름이 없습니다. 실제로 동작하는 예를 살펴봅시다.

let add = |l: i32, r:i32| -> i32 { 
    l + r 
};
println!("{}", add(1, 2));

클로저는 함수와는 다르게 이름이 없고 필요할 때 즉석에서 생성되고 사라지는 동적 함수(dynamic function)입니다. 따라서 위에서는 이 클로저를 특정 변수(add)에 대입하고 이 변수를 호출하는 식으로 구현했습니다. 기능 구현 코드에서 return을 표기해서 명확하게 반환해도 되지만, 함수 편에서 설명한 식(expression) 방식으로도 구현이 가능합니다. 결과적으로 위의 함수 편에서 만든 add() 함수와 동일한 동작을 하는 코드가 완성되었네요.

클로저 압축하기

러서트의 클로저 문법은 코드를 좀 더 축약시키는 것이 가능합니다. 러스트 특유의 타입 추론 시스템을 활용하는 방식입니다. 위의 add 변수에 대입한 클로저는 아래처럼 축약시킬 수 있습니다.

let add = |l, r| { l + r };

add에 대입한 클로저 모양에서 타입 선언이 완전히 사라졌습니다. 이러고도 컴파일이 가능합니다. 아무런 에러 없이요.

사실 위와 같이 타입을 극단적으로 빼버린 경우는 컴파일러가 이를 호출하는 부분의 코드에서 타입을 추론합니다. 예를 들어 아래와 같은 코드를 추가로 구현했다고 칩시다.

println!("{}", add(1.2, 2.3));
println!("{}", add(1, 2));

만약 위 둘 중 하나의 줄만 구현되어 있다면 컴파일러는 아무런 에러를 뱉지 않습니다. 왜냐하면 add에 대입된 클로저의 각 매개변수와 반환 타입을 추론할 힌트가 호출하는 코드에서 보이니깐요.

하지만 위의 두 줄을 모두 코딩했다면 결국 타입을 추론할 힌트가 이것저것 섞여 있는 셈이라서 컴파일러가 타입을 추론하지 못하고 에러가 발생하게 됩니다.

클로저 더 압축하기

놀랍게도 러스트의 클로저는 여기서 좀 더 코드를 줄이는 것이 가능합니다. 아예 중괄호를 빼서 극단적으로 줄일 수 있지요.

let add = |l, r| l + r;

끝에 세미콜론이 붙어있다는 점에서 혼돈이 올 수 있지만, 이 표현도 결국 식(expression) 문법입니다. 세미콜론은 add 변수의 선언이 여기까지 다라는 표시일 뿐이니깐요.

클로저 선언과 동시에 실행시키기

지금까지의 클로저 예제는 특정 변수에 클로저를 대입해서 실행시켰는데, 만약 일회성 클로저라면 굳이 변수에 대입할 필요 없이 이름 없는 상태에서 실행시켜서 결과를 받아오는 것이 가능합니다.

let one_plus_two = |l, r| -> i32 { l + r }(1, 2);

마치 특정 변수에 클로저를 대입하는 것처럼 보이기도 하지만, 끝의 괄호의 모습이 마치 호출할 때의 모습과 비슷하다는 것에서 힌트를 얻으면 구분할 수 있는 코드입니다. 즉 위의 클로저가 1, 2라는 매개변수를 입력으로 실행된 결과가 one_plus_two라는 변수에 대입되게 됩니다. 뭐 풀어보자면 아래와 같은 코드가 위 한 줄로 해결된다는 말이겠지요.

let add = |l, r| l + r;
let one_plus_two = add(1, 2);

이런 두 줄의 코드가 한 줄로 줄어들게 됩니다.

다만 뭔가의 제약도 보이는데, 저렇게 선언과 동시에 일회성으로 실행시키는 클로저는 반드시 반환 타입을 명시해야 합니다. 위에서도 클로저의 반환 타입을 i32로 명시했는데 이걸 빼먹으면 컴파일러가 타입 추론에 실패하며 에러를 내뱉습니다.

 

맺음말

함수는 사실 대단히 흔한 모양새라 이해하기 매우 쉬웠습니다. 다만 return과 기타 몇몇 코드를 생략할 수 있는 식(expression) 문법이 약간 생소하긴 했지만 역시 어렵진 않았지요. 심지어 클로저도 축약 형태가 생소할 뿐이지 다른 언어들에서 봐오던 모습과 별로 다를 게 없었네요.

다만 클로저를 설명할 때면 항상 등장하는 납치(capture) 문제를 이번 편에서 설명하긴 좀 힘드네요. 러스트 특유의 메모리 관리시스템과 접목하여 여러 다른 상황이 발생하는 것 같은데 다음에 기회가 되면 이 문제도 다뤄볼 수 있으면 좋을 것 같습니다.

728x90
반응형

댓글