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편을 기대하자.