본문 바로가기

함수

함수의 매개변수와 반환 타입을 선언하고 구문과 표현식을 구분해 블록의 마지막 값으로 결과를 반환합니다.

러스트 코드는 함수로 가득합니다. 이미 많은 프로그램의 시작점인 main 함수와, 새 함수를 선언하는 fn 키워드를 보았습니다. 이 글에서는 함수를 정의하고 부르는 방법, 매개변수로 값을 넘기는 방법, 그리고 러스트에서 함수가 값을 돌려주는 방식을 이해하는 데 필요한 구문과 표현식의 차이를 살펴봅니다.

함수 정의하고 부르기

러스트에서는 함수와 변수의 이름을 모두 소문자로 쓰고 단어 사이를 밑줄로 잇는 스네이크 케이스(snake case)로 짓는 것이 관례입니다. cargo new functions로 새 프로젝트를 만들고 src/main.rs에 다음 코드를 넣습니다.

src/main.rs
fn main() {
    println!("Hello, world!");

    another_function();
}

fn another_function() {
    println!("Another function.");
}

함수는 fn 뒤에 이름과 괄호를 적어 정의하며, 중괄호가 본문의 시작과 끝을 알려 줍니다. 정의한 함수는 이름 뒤에 괄호를 붙여 부릅니다. another_function은 main보다 뒤에 정의되어 있지만, 러스트는 함수가 소스의 어디에 있는지 따지지 않고 부르는 쪽에서 볼 수 있는 범위 어딘가에 정의되어 있기만 하면 됩니다. 실행하면 main의 문장이 차례로 실행되어, 먼저 Hello, world!를 출력한 뒤 another_function을 불러 그 안의 메시지를 출력합니다.

Hello, world!
Another function.

매개변수

함수에는 매개변수(parameter)를 둘 수 있습니다. 매개변수는 함수 시그니처(signature)의 일부인 특별한 변수로, 함수를 부를 때 넘긴 구체적인 값을 받습니다. 엄밀히는 이 구체적인 값을 인수(argument)라고 하지만, 일상적으로는 함수 정의의 변수와 부를 때 넘기는 값을 구분하지 않고 둘 다 매개변수나 인수라고 부르기도 합니다.

src/main.rs
fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {x}");
}
The value of x is: 5

another_function은 i32 타입의 매개변수 x 하나를 받습니다. 5를 넘겨 부르면 x가 5가 되고, println!이 그 값을 자리표시자 자리에 넣어 출력합니다. 함수 시그니처에서는 매개변수마다 타입을 반드시 적어야 하는데, 이는 러스트를 설계할 때 일부러 정한 규칙입니다. 함수 정의에 타입이 적혀 있으면 컴파일러가 그 함수를 부르는 코드를 보고 의도한 타입을 짐작할 필요가 없고, 기대한 타입을 알고 있으므로 더 도움이 되는 오류 메시지를 낼 수 있습니다.

매개변수가 여러 개라면 쉼표로 나눕니다.

src/main.rs
fn main() {
    print_labeled_measurement(5, 'h');
}

fn print_labeled_measurement(value: i32, unit_label: char) {
    println!("The measurement is: {value}{unit_label}");
}
The measurement is: 5h

첫 매개변수 value는 i32, 둘째 매개변수 unit_label은 char이며, 5와 'h'를 넘겼으므로 두 값이 이어진 5h가 출력됩니다.

구문과 표현식

함수의 본문은 구문(statement)이 이어지다가, 마지막에 표현식(expression)이 하나 올 수도 있습니다. 러스트는 표현식 기반의 언어이므로 이 둘의 차이를 아는 것이 중요하며, 다른 언어에는 이런 구분이 없거나 다를 수 있습니다. 구문은 어떤 동작을 하지만 값을 돌려주지 않는 명령이고, 표현식은 계산되어 값이 됩니다.

let으로 변수를 만들고 값을 넣는 것은 구문입니다. 다음 main의 let y = 6;도 구문이며, 함수 정의 자체도 구문입니다.

src/main.rs
fn main() {
    let y = 6;
}

구문은 값을 돌려주지 않으므로, let 구문을 다른 변수에 넣을 수 없습니다. 다음 코드는 컴파일되지 않습니다.

src/main.rs (컴파일되지 않음)
fn main() {
    let x = (let y = 6);
}
error: expected expression, found `let` statement
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: only supported directly in conditions of `if` and `while` expressions

표현식이 와야 할 자리에 let 구문이 왔다는 오류이며, 괄호가 필요 없다는 경고도 함께 나옵니다. let은 if와 while의 조건에서만 직접 쓸 수 있다는 안내가 붙는데, 그 쓰임은 if let을 사용한 간결한 제어 흐름과 패턴이 사용될 수 있는 모든 곳에서 다룹니다. C나 Ruby처럼 대입이 대입한 값을 돌려주는 언어에서는 x = y = 6으로 두 변수에 6을 넣을 수 있지만, 러스트의 대입은 값 대신 유닛 ()이 됩니다. 그래서 x = y = 6은 y에 6을 넣은 뒤 x에는 ()을 넣으려 하므로, x가 정수 변수라면 타입이 맞지 않아 컴파일되지 않습니다.

러스트 코드는 대부분 표현식입니다. 5 + 6 같은 계산은 11이라는 값이 되는 표현식이고, let y = 6;의 6도 6이 되는 표현식입니다. 함수 호출과 매크로 호출도 표현식이며, 중괄호로 만든 새 범위의 블록도 표현식입니다.

src/main.rs
fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {y}");
}
The value of y is: 4

y에 넣은 중괄호 블록은 4가 되는 표현식입니다. 블록 안에서 x에 3을 넣은 뒤 마지막 줄의 x + 1이 블록의 값이 되고, 그 값이 let 구문에 따라 y에 묶입니다. 마지막 줄 x + 1에 세미콜론이 없다는 점에 주의합니다. 표현식 끝에 세미콜론을 붙이면 그 값을 버리는 구문이 되어 블록의 마지막 값이 없어지고, 그러면 블록은 유닛 ()이 됩니다. 이 규칙은 함수가 값을 돌려주는 방식으로 그대로 이어집니다.

반환 값을 갖는 함수

함수는 부른 쪽에 값을 돌려줄 수 있습니다. 돌려주는 값에 이름을 붙이지는 않지만, 그 타입은 화살표 -> 뒤에 적어야 합니다. 함수의 반환 값은 본문 블록의 마지막 표현식의 값이며, return 키워드로 값을 지정해 중간에 돌아갈 수도 있지만 대부분의 함수는 마지막 표현식으로 값을 돌려줍니다.

src/main.rs
fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("The value of x is: {x}");
}
The value of x is: 5

five 함수의 본문에는 함수 호출도, let 구문도 없이 숫자 5 하나만 있지만, 러스트에서는 올바른 함수입니다. 본문의 마지막 표현식 5가 반환 값이고, 그래서 반환 타입을 -> i32로 적었습니다. let x = five();는 함수가 돌려준 값으로 변수를 초기화하며, five가 5를 돌려주므로 let x = 5;와 같습니다.

매개변수와 반환 값을 함께 쓰는 예를 봅니다.

src/main.rs
fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1
}
The value of x is: 6

main의 x와 plus_one의 매개변수 x는 이름만 같을 뿐 서로 다른 변수입니다. 매개변수 x가 인수 5를 받고, 본문의 x + 1이 6을 돌려주며, main은 그 6을 자기 변수 x에 묶습니다. 그런데 x + 1 끝에 세미콜론을 붙여 표현식을 구문으로 바꾸면 컴파일되지 않습니다.

src/main.rs (컴파일되지 않음)
fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}
error[E0308]: mismatched types
 --> src/main.rs:7:24
  |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: remove this semicolon to return this value

핵심은 타입이 맞지 않는다(mismatched types)는 것입니다. plus_one은 i32를 돌려준다고 선언했지만, x + 1;은 계산한 값을 버리는 구문이어서 본문에 마지막 표현식이 남지 않습니다. 그래서 본문 블록은 유닛 ()이 되고, 선언한 반환 타입 i32와 맞지 않습니다. 컴파일러는 이 세미콜론을 지우면 값을 돌려줄 수 있다는 도움말까지 보여 줍니다.

아래 그림은 같은 i32 반환 시그니처에서 세미콜론 하나가 본문 블록의 타입을 어떻게 바꾸는지 비교합니다. x + 1은 i32 값을 남겨 선언과 맞고, x + 1;은 값을 버려 블록이 ()이 되므로 E0308 오류가 납니다. 두 경로는 함께 실행되는 순서가 아니라 서로 다른 두 사본입니다.

같은 i32 반환 시그니처에서 끝 피연산자 x + 1은 i32 계약을 만족하지만 세미콜론이 붙은 x + 1;은 결과를 버려 블록이 단위 타입이 되는 과정을 비교

RUST · RETURN CONTRACT

세미콜론은 블록의 값과 반환 타입을 바꾼다

x + 1은 i32 값을 남기고, x + 1;은 계산 결과를 버립니다. 정상적으로 끝까지 실행되는 함수 본문은 선언한 출력 타입을 만족해야 합니다.

같은 i32 출력 계약의 두 독립 타입 추론 경로6노드4간선이다. x+1은i32꼬리값→블록i32→PASS, x+1세미콜론은결과버림→블록unit→E0308로따로진행한다. 공통출력i32와정상적으로끝에도달한블록의타입을대조하는구획이며성공실행뒤실패를실행한다는순서가아니다. return과발산조건은보존한본문별도참조다. desktop/mobile관계동일.꼬리 표현식의 경로x + 1i32 값을 남김블록 타입꼬리값의 타입 i32PASS본문 i32 = 출력 i32결과를 버린 경로x + 1;표현식 구문: 결과 버림블록 타입끝까지 도달: ()FAIL · E0308본문 () ≠ 출력 i32같은 선언: i32를 반환두 사본을 함께 실행하는 순서 아님
같은 i32 출력 계약의 두 독립 타입 추론 경로6노드4간선이다. x+1은i32꼬리값→블록i32→PASS, x+1세미콜론은결과버림→블록unit→E0308로따로진행한다. 공통출력i32와정상적으로끝에도달한블록의타입을대조하는구획이며성공실행뒤실패를실행한다는순서가아니다. return과발산조건은보존한본문별도참조다. desktop/mobile관계동일.꼬리 표현식의 경로x + 1i32 값을 남김블록 타입꼬리값의 타입 i32PASS본문 i32 = 출력 i32결과를 버린 경로x + 1;표현식 구문: 결과 버림블록 타입끝까지 도달: ()FAIL · E0308본문 () ≠ 출력 i32같은 선언: i32를 반환두 사본을 함께 실행하는 순서 아님

공통 출력 계약 · i32

fn plus_one은 i32 인수를 받고, 함수가 정상적으로 반환하면 호출자에게 i32 값을 내보내겠다고 선언합니다.

끝 피연산자가 있는 경로

fn plus_one(x: i32) -> i32 {
    x + 1
}
  1. 1 · 끝 피연산자

    x + 1이 블록의 마지막 표현식으로 평가되어 i32 값을 남깁니다.

  2. 2 · 블록 타입

    끝 피연산자의 타입을 따라 본문 블록도 i32입니다.

  3. PASS · 타입 일치

    본문 i32 = 출력 i32이므로 호출자에게 계산 결과를 반환할 수 있습니다.

세미콜론으로 결과를 버린 경로

fn plus_one(x: i32) -> i32 {
    x + 1;
}
  1. 1 · 결과 버림

    x + 1;은 표현식 구문입니다. 계산은 하지만 세미콜론 때문에 그 결과를 사용하지 않습니다.

  2. 2 · 블록 타입

    끝 피연산자가 없고 실행이 끝까지 도달하므로 본문 블록은 단위 타입 ()입니다.

  3. FAIL · E0308

    본문 () ≠ 출력 i32이므로 컴파일러가 기대한 i32와 발견한 ()의 계약 불일치를 알립니다.

return value;는 도달한 자리에서 일찍 반환하며, 그 값도 선언한 출력 타입과 맞아야 합니다. 끝까지 도달하지 않는 발산 함수는 별도 경우입니다.

정리

  • 함수는 fn으로 정의하고, 이름은 스네이크 케이스로 지으며, 부르는 쪽에서 볼 수 있는 범위에 있기만 하면 정의한 위치는 상관없습니다.
  • 매개변수는 함수 시그니처에서 타입을 반드시 적고, 여러 개라면 쉼표로 나눕니다.
  • 구문은 동작을 하지만 값을 돌려주지 않으며, let은 구문이라 다른 변수에 넣을 수 없습니다.
  • 표현식은 값이 되며, 블록은 마지막 표현식의 값이 됩니다.
  • 표현식 끝에 세미콜론을 붙이면 값을 버리는 구문이 되고, 마지막 표현식이 없는 블록은 ()이 됩니다.
  • 함수의 반환 타입은 -> 뒤에 적고, 본문의 마지막 표현식이나 return으로 값을 돌려줍니다.

연습 문제

1. 호출 전후의 값

다음 프로그램은 무엇을 출력하나요? plus_one이 source 자체를 바꾸는지, 매개변수 x가 source와 어떤 관계인지를 생각해 보세요.

src/main.rs
fn plus_one(x: i32) -> i32 {
    x + 1
}

fn main() {
    let source = 8;
    let result = plus_one(source);
    println!("{source} {result}");
}
해설 보기

8 9를 출력합니다. plus_one의 매개변수 x는 source의 값 8을 받은 별개의 변수이며, x + 1인 9를 돌려주어 result에 묶입니다. i32 같은 정수는 넘길 때 값이 복사되므로 source는 그대로 8입니다. 모든 타입이 이렇게 복사되는 것은 아니며, String처럼 복사되지 않는 타입은 소유권이 뭔가요?에서 다룹니다.

2. 세미콜론과 return

plus_one의 본문을 각각 x + 1;과 return x + 1;로 바꾼 두 사본이 있습니다. 둘 다 컴파일되나요?

해설 보기

x + 1;만 있는 사본은 계산한 값을 버리고 본문 끝에서 ()이 되므로, i32를 돌려준다는 선언과 맞지 않아 E0308 오류로 컴파일되지 않습니다. return x + 1;은 세미콜론이 붙어 있어도 return이 그 값을 함수 밖으로 돌려주므로 컴파일되며, x + 1과 같은 결과를 냅니다. 마지막 줄의 세미콜론만 볼 것이 아니라, 함수 밖으로 값을 보내는 길이 있는지를 봐야 합니다.

3. 블록의 값

다음 코드에서 y는 무엇이 되나요? x * 2 뒤에 세미콜론을 붙이면 어떻게 되나요?

let y = {
    let x = 10;
    x * 2
};
해설 보기

블록의 마지막 표현식 x * 2가 20이므로 y는 20입니다. 세미콜론을 붙이면 x * 2;는 값을 버리는 구문이 되어 블록의 값이 ()이 되고, y도 ()이 됩니다. 이 코드 자체는 컴파일되지만, 그 뒤에 y를 정수로 쓰려 하면 타입이 맞지 않아 오류가 납니다.

다음 글 주석에서는 코드에 설명을 남기는 일반 주석과, 문서를 만드는 문서화 주석을 살펴봅니다.