V 언어 문법: 함수 정의부터 클로저까지

V 언어 문법: 함수 정의부터 클로저까지

2편에서 변수와 타입을 배웠다면, 이제 그 데이터를 가지고 무언가를 하는 법을 배울 차례다. 프로그래밍에서 "무언가를 한다"는 것은 곧 함수(function) 를 만드는 것이다. 이번 편에서는 V의 함수 문법을 처음부터 끝까지 훑어본다.

함수란?

함수를 한 줄로 정의하면 이렇다.

함수 = "입력을 받아서, 정해진 작업을 하고, 결과를 돌려주는 코드 묶음"

비유하자면 자판기와 비슷하다.

  • 입력(매개변수): 동전과 버튼 번호를 넣는다
  • 처리(함수 본문): 자판기 내부에서 음료를 꺼낸다
  • 출력(반환값): 음료가 나온다

함수를 왜 쓸까? 같은 코드를 반복해서 쓰지 않기 위해서다. 인사하는 코드를 100번 쓰는 대신, 인사 함수를 한 번 만들어두고 100번 호출하면 된다.


fn 키워드로 함수 정의하기

V에서 함수는 fn 키워드로 정의한다. 1편에서 이미 본 fn main()이 바로 함수다.

가장 간단한 함수

먼저, 아무것도 받지 않고 아무것도 돌려주지 않는 함수를 만들어보자.

fn say_hello() {
    println('안녕하세요!')
}

fn main() {
    say_hello()   // 함수 호출 → '안녕하세요!' 출력
    say_hello()   // 두 번 호출해도 된다
}

함수의 구조를 분해해보자.

fn say_hello() {
│   │          │ │
│   │          │ └─ 함수 본문 시작
│   │          └─── 매개변수 목록 (비어있음)
│   └────────────── 함수 이름
└────────────────── fn 키워드 ("나는 함수다!")
  • fn — "지금부터 함수를 정의하겠다"라는 선언
  • say_hello — 함수의 이름 (snake_case로 짓는다)
  • () — 매개변수(입력) 목록. 지금은 비어있다
  • { ... } — 중괄호 안의 코드가 함수의 본문이다

매개변수와 반환 타입

자판기에 동전을 넣듯, 함수에도 값을 넣어줄 수 있다. 이 값을 매개변수(parameter) 라고 한다. 그리고 자판기에서 음료가 나오듯, 함수도 결과를 돌려줄 수 있다. 이것을 반환값(return value) 이라고 한다.

매개변수 있는 함수

fn greet(name string) {
    println('안녕, ${name}!')
}

fn main() {
    greet('홍길동')    // 안녕, 홍길동!
    greet('김영희')    // 안녕, 김영희!
}

name string이 매개변수 선언이다. 이름이 먼저, 타입이 뒤에 온다.

🔍 다른 언어와 비교:

언어 매개변수 순서 예시
V 이름 → 타입 fn greet(name string)
Go 이름 → 타입 func greet(name string)
Python 이름만 (타입 생략 가능) def greet(name):
C 타입 → 이름 void greet(char* name)
Java 타입 → 이름 void greet(String name)

V와 Go는 같은 순서다. C/Java와는 반대인 셈이다. V의 설계 철학은 "영어 문장처럼 읽어라"인데, name string은 "name은 string이다"로 읽히니 나름 자연스럽다.

매개변수가 여러 개인 함수

fn introduce(name string, age int) {
    println('${name}, ${age}살')
}

fn main() {
    introduce('홍길동', 25)    // 홍길동, 25살
}

매개변수는 쉼표(,)로 구분한다. 같은 타입이라도 각각 타입을 명시해야 한다.

반환값이 있는 함수

함수가 결과를 돌려주게 하려면, return 키워드를 쓰고, 함수 선언에 반환 타입을 적어준다.

fn add(a int, b int) int {
    return a + b
}

fn main() {
    result := add(3, 5)
    println(result)         // 8
    println(add(10, 20))    // 30 — 바로 사용할 수도 있다
}

분해해보면 이렇다.

fn add(a int, b int) int {
                     ───
                      └─ 반환 타입: 이 함수는 int를 돌려준다

매개변수 목록 ) 뒤에 적은 int가 반환 타입이다. 이 함수는 "정수 두 개를 받아서, 정수 하나를 돌려준다"고 선언한 것이다.

실전 예제: 원의 넓이 계산

fn circle_area(radius f64) f64 {
    return 3.14159 * radius * radius
}

fn main() {
    area := circle_area(5.0)
    println('반지름 5인 원의 넓이: ${area}')  // 반지름 5인 원의 넓이: 78.53975
}

radius라는 실수를 받아서, 원의 넓이(실수)를 돌려주는 함수다.


다중 반환값

V의 함수는 값을 두 개 이상 동시에 반환할 수 있다. 이 기능은 매우 유용하면서도, 다른 언어에서는 흔하지 않은 기능이다.

왜 필요할까?

나눗셈 함수를 생각해보자. 결과값(몫)만 돌려준다면, 0으로 나누는 경우를 어떻게 알려줄까? 다중 반환값을 쓰면 "결과"와 "성공 여부"를 동시에 돌려줄 수 있다.

fn divide(a f64, b f64) (f64, bool) {
    if b == 0 {
        return 0, false    // 0으로 나누기 시도 → 실패
    }
    return a / b, true     // 정상 → 결과와 성공 표시
}

fn main() {
    result, ok := divide(10, 3)
    if ok {
        println('결과: ${result}')   // 결과: 3.3333333333333335
    }

    result2, ok2 := divide(10, 0)
    if !ok2 {
        println('0으로 나눌 수 없습니다!')  // 이 메시지가 출력된다
    }
}

반환 타입을 (f64, bool)처럼 괄호로 묶어서 여러 타입을 나열하면 된다. 호출하는 쪽에서는 result, ok := divide(10, 3)처럼 쉼표로 변수를 나열해서 각 반환값을 받는다.

필요 없는 반환값 무시하기: _

반환값 중 일부가 필요 없다면 _(밑줄)을 사용해서 무시할 수 있다.

fn get_user_info() (string, int, string) {
    return '홍길동', 25, 'Seoul'
}

fn main() {
    name, _, city := get_user_info()   // 나이(두 번째 값)는 무시
    println('${name} from ${city}')    // 홍길동 from Seoul
}

_는 "이 값은 받긴 하지만 쓰지 않겠다"는 뜻이다. V 컴파일러는 사용하지 않는 변수가 있으면 경고를 내기 때문에, 의도적으로 무시할 때 _를 써주면 경고도 사라진다.

🔍 다른 언어와 비교:

  • Go: 다중 반환값을 기본 지원한다 — func divide(a, b float64) (float64, bool)
  • Python: 튜플로 흉내낸다 — return result, True (언어 차원의 다중 반환은 아님)
  • C/Java: 다중 반환값을 지원하지 않는다 — 구조체나 객체로 감싸야 한다

V의 다중 반환값은 Go와 거의 동일한 방식이다.


기본값 매개변수

함수를 호출할 때 일부 인자를 생략할 수 있게 만들고 싶다면, 매개변수에 기본값(default value) 을 넣어주면 된다. V에서는 이를 위해 구조체를 활용한 설정 패턴을 사용한다.

일반적인 함수에서는 매개변수를 직접 생략할 수 없지만, 실전에서 자주 쓰는 패턴을 하나 보여주겠다.

struct GreetConfig {
    name     string = '손님'        // 기본값: '손님'
    greeting string = '안녕하세요'  // 기본값: '안녕하세요'
    times    int    = 1             // 기본값: 1
}

fn greet_with(config GreetConfig) {
    for _ in 0 .. config.times {
        println('${config.greeting}, ${config.name}!')
    }
}

fn main() {
    // 모든 값을 기본값으로 사용
    greet_with(GreetConfig{})
    // 출력: 안녕하세요, 손님!

    // 원하는 값만 지정 (나머지는 기본값)
    greet_with(GreetConfig{ name: '홍길동', times: 3 })
    // 출력:
    // 안녕하세요, 홍길동!
    // 안녕하세요, 홍길동!
    // 안녕하세요, 홍길동!
}

구조체의 기본값 필드를 활용하면, 호출 시 원하는 옵션만 지정하고 나머지는 기본값을 쓸 수 있다. 이 패턴은 설정이 많은 함수에서 특히 유용하다. 구조체에 대해서는 6편에서 더 자세히 다룬다.


익명 함수

지금까지 본 함수는 모두 fn say_hello() 처럼 이름이 있는 함수였다. V에서는 이름 없는 함수도 만들 수 있다. 이것을 익명 함수(anonymous function) 라고 한다.

왜 이름 없는 함수가 필요할까?

함수가 딱 한 곳에서만 쓰인다면, 굳이 이름을 붙여 따로 정의하는 것보다 사용하는 자리에서 바로 만드는 것이 더 깔끔할 때가 있다. 마치 "일회용 도구"를 만드는 것이다.

fn main() {
    // 익명 함수를 변수에 저장
    double := fn (n int) int {
        return n * 2
    }

    println(double(5))    // 10
    println(double(21))   // 42
}

fn (n int) int { ... } 부분이 익명 함수다. 이름이 없는 대신 double이라는 변수에 저장해서 사용한다. 이후 double(5)처럼 일반 함수와 동일하게 호출할 수 있다.

즉시 실행하기

익명 함수를 만들자마자 바로 실행할 수도 있다.

fn main() {
    result := fn (a int, b int) int {
        return a + b
    }(3, 7)                // ← 만들자마자 (3, 7)을 넣어 바로 호출

    println(result)        // 10
}

함수 정의 직후에 (3, 7)을 붙여서 바로 호출했다. 자주 쓰는 패턴은 아니지만, 알아두면 유용한 기법이다.


클로저 (Closure)

클로저(closure) 는 익명 함수의 확장판이다. 핵심 특징은 바깥 변수를 기억한다는 것이다.

바깥 변수를 기억한다?

fn main() {
    greeting := '안녕'     // 바깥(main) 변수

    say := fn [greeting] () {     // greeting을 "캡처"
        println('${greeting}, V!')
    }

    say()   // 안녕, V!
}

fn [greeting] ()에서 대괄호 [greeting]이 핵심이다. 이것은 "바깥에 있는 greeting 변수를 이 함수 안에서도 쓰겠다" 는 선언이다. V에서는 이 동작을 캡처(capture) 라고 부른다.

💡 비유: 클로저는 "도시락 싸기"와 비슷하다. 집(바깥 함수)에서 음식(변수)을 도시락([변수이름])에 담아서 나간다. 집을 떠나도(바깥 함수가 끝나도) 도시락 속 음식은 여전히 먹을 수 있다.

변경 가능한 캡처

바깥 변수의 값을 변경하고 싶다면, mut을 사용해야 한다.

fn main() {
    mut count := 0

    increment := fn [mut count] () {
        count += 1
        println('count: ${count}')
    }

    increment()   // count: 1
    increment()   // count: 2
    increment()   // count: 3
}

[mut count]로 캡처하면 클로저 안에서 count의 값을 바꿀 수 있다.

⚠️ 주의: 캡처된 값은 클로저 내부의 복사본이다. 클로저 안에서 count를 바꿔도, 바깥의 원래 count 변수에는 영향을 주지 않는다. 이것은 V가 안전성을 위해 의도적으로 설계한 동작이다.

fn main() {
    mut x := 10

    add_five := fn [mut x] () {
        x += 5
        println('클로저 안: ${x}')   // 15
    }

    add_five()
    println('클로저 밖: ${x}')       // 10 — 바깥 x는 변하지 않았다!
}

클로저가 실전에서 유용한 경우

클로저는 콜백(callback) 패턴에서 가장 많이 쓰인다. 예를 들어, 리스트의 각 요소에 특정 작업을 적용할 때 유용하다.

fn main() {
    numbers := [1, 2, 3, 4, 5]
    multiplier := 10

    // map에 클로저를 전달하여 각 요소에 multiplier를 곱한다
    result := numbers.map(fn [multiplier] (n int) int {
        return n * multiplier
    })

    println(result)   // [10, 20, 30, 40, 50]
}

[multiplier]로 바깥 변수를 캡처했기 때문에, 클로저 안에서 multiplier 값을 사용할 수 있다.


고차 함수

고차 함수(higher-order function)함수를 인자로 받거나, 함수를 반환하는 함수를 말한다.

"함수를 인자로 받는다"는 말이 처음에는 낯설 수 있다. 하지만 이미 클로저 예제에서 numbers.map(...)이 함수를 인자로 받는 것을 봤다. 이것이 바로 고차 함수다.

함수를 인자로 받는 함수

// 두 정수와 "연산 함수"를 받아서 결과를 출력하는 함수
fn apply(a int, b int, operation fn (int, int) int) {
    result := operation(a, b)
    println('결과: ${result}')
}

fn main() {
    // 더하기 함수
    add := fn (x int, y int) int { return x + y }
    // 곱하기 함수
    mul := fn (x int, y int) int { return x * y }

    apply(3, 5, add)    // 결과: 8
    apply(3, 5, mul)    // 결과: 15
}

apply 함수의 세 번째 매개변수를 보자.

operation fn (int, int) int
───────── ─────────────────
  이름      함수 타입: "int 두 개를 받아 int를 돌려주는 함수"

operation은 함수 자체를 담는 변수다. apply를 호출할 때 어떤 연산 함수를 넘기느냐에 따라 동작이 달라진다. 이것이 고차 함수의 힘이다 — 동작을 주입할 수 있다.

함수를 반환하는 함수

함수가 함수를 만들어서 돌려줄 수도 있다.

// "배수 만들기 함수"를 돌려주는 함수
fn make_multiplier(factor int) fn (int) int {
    return fn [factor] (n int) int {
        return n * factor
    }
}

fn main() {
    double := make_multiplier(2)    // 2배를 만드는 함수
    triple := make_multiplier(3)    // 3배를 만드는 함수

    println(double(5))    // 10
    println(triple(5))    // 15
    println(double(100))  // 200
}

make_multiplier(2)를 호출하면 "입력값에 2를 곱하는 함수"가 만들어져서 돌아온다. factor 값을 클로저로 캡처([factor])하고 있기 때문에, 나중에 double(5)를 호출할 때도 factor가 2라는 것을 기억한다.

💡 처음에 어렵게 느껴진다면: 고차 함수는 처음 접하면 헷갈릴 수 있다. "함수를 값처럼 주고받을 수 있다"는 개념만 기억하고, 코드를 여러 번 읽어보면 자연스럽게 익숙해진다. 5편에서 배열의 map, filter, sort 등을 사용할 때 다시 만나게 된다.


defer 문 — 함수가 끝날 때 반드시 실행

defer"이 함수가 끝나기 직전에 꼭 이 코드를 실행해줘" 라고 예약해두는 문법이다.

왜 필요할까?

파일을 열면 반드시 닫아야 하고, 데이터베이스 연결을 맺으면 반드시 끊어야 한다. 하지만 함수 중간에 return이 여러 개 있으면, 모든 return 앞에 "닫기" 코드를 넣어야 해서 실수하기 쉽다.

defer를 쓰면 어디서 함수가 끝나든 정리 코드가 자동으로 실행된다.

fn read_data() {
    println('1. 파일을 연다')
    defer {
        println('4. 파일을 닫는다 (defer)')  // 함수가 끝날 때 실행
    }
    println('2. 데이터를 읽는다')
    println('3. 처리를 마친다')
}

fn main() {
    read_data()
}

출력:

1. 파일을 연다
2. 데이터를 읽는다
3. 처리를 마친다
4. 파일을 닫는다 (defer)

defer { ... } 블록은 코드 중간에 적혀 있지만, 실제로는 함수가 끝날 때 실행된다. 마치 "나가기 전에 불 꺼줘"라고 메모를 붙여두는 것과 같다.

여러 개의 defer

defer를 여러 개 쓰면 역순(LIFO) 으로 실행된다. 마지막에 등록한 것이 먼저 실행된다.

fn example() {
    defer { println('3번째 등록, 1번째 실행') }
    defer { println('2번째 등록, 2번째 실행') }
    defer { println('1번째 등록, 3번째 실행') }
    println('함수 본문 실행')
}

fn main() {
    example()
}

출력:

함수 본문 실행
3번째 등록, 1번째 실행
2번째 등록, 2번째 실행
1번째 등록, 3번째 실행

💡 왜 역순일까? 자원을 열 때와 닫을 때의 순서를 생각해보자. 문을 열고 → 불을 켜면, 나갈 때는 불을 끄고 → 문을 닫는 것이 자연스럽다. 먼저 연 것을 나중에 닫는 것이 안전하다. defer의 역순 실행은 이 원칙을 자동으로 지켜준다.


함수에 대해 더 알아둘 것들

함수와 불변 매개변수

V의 함수 매개변수는 기본적으로 불변이다. 함수 안에서 매개변수의 값을 바꿀 수 없다.

fn try_modify(x int) {
    // x = 10    // ❌ 에러! 매개변수는 불변
    println(x)
}

이것은 2편에서 배운 V의 "불변이 기본" 철학과 일관된다. 함수가 전달받은 값을 몰래 바꾸는 일을 방지해준다.


📝 정리

이번 글에서 배운 핵심 포인트를 체크리스트로 정리한다.

  • [x] fn 키워드로 함수 정의fn 이름(매개변수) 반환타입 { ... }
  • [x] 매개변수이름 타입 순서 (V/Go 스타일), 기본적으로 불변
  • [x] 반환값return으로 결과 돌려주기, 반환 타입 명시 필수
  • [x] 다중 반환값(타입1, 타입2) 형태, _로 불필요한 값 무시 가능
  • [x] 익명 함수 — 이름 없이 fn (매개변수) 반환타입 { ... }로 정의, 변수에 저장 가능
  • [x] 클로저fn [캡처변수] (...) { ... }로 바깥 변수를 캡처 (복사본)
  • [x] 고차 함수 — 함수를 인자로 받거나 함수를 반환하는 함수
  • [x] defer — 함수 종료 시 자동 실행, 여러 개면 역순(LIFO) 실행

🧪 직접 해보기

과제 1: BMI 계산기

키(cm)와 몸무게(kg)를 받아 BMI를 계산하는 함수를 만들어보자.

BMI = 몸무게(kg) / (키(m))²
fn calc_bmi(height_cm f64, weight_kg f64) f64 {
    // 여기를 완성하세요!
    // 힌트: cm를 m로 변환하려면 100으로 나눈다
    return 0.0
}

fn main() {
    bmi := calc_bmi(175.0, 70.0)
    println('BMI: ${bmi}')  // 약 22.86이 나와야 한다
}

과제 2: 안전한 나누기

divide 함수를 만들어보자. 0으로 나누는 경우를 다중 반환값으로 처리해야 한다.

  • 정상: (몫, true) 반환
  • 0으로 나누기: (0.0, false) 반환
fn divide(a f64, b f64) (f64, bool) {
    // 여기를 완성하세요!
}

fn main() {
    q, ok := divide(10, 3)
    if ok {
        println('10 / 3 = ${q}')
    }

    q2, ok2 := divide(5, 0)
    if !ok2 {
        println('0으로 나눌 수 없습니다')
    }
}

다음 편 예고

4편: V 언어 문법 — 조건문, 반복문, 패턴 매칭

프로그램의 흐름을 제어하는 방법을 배운다. if/else, 값을 반환하는 if, 강력한 match(패턴 매칭), 그리고 for 루프의 4가지 형태까지 — 프로그램에 "두뇌"를 달아주는 4편을 기대하자.