1. 함수의 정의와 호출
앞서 프로그램은 사용자가 입력한 값을 가공하여 그 결과를 다시 사용자에게 돌려주는 것이라고 설명했다.
혹자는 이를 수학의 함수에 빗대어 설명하기도 한다.
프로그램을 큰 틀에서 보면 하나의 수학적 함수처럼 보이지만, 내부를 들여다보면 수많은 함수들이 서로 조합되어 하나의 프로그램을 구성하고 있음을 알 수 있다.
이처럼 함수는 프로그램을 이루는 핵심 요소라 할 수 있다.
코딩에서 함수란 특정 작업을 수행하는 독립적인 코드 블록을 의미하며, 코드 블록에 이름을 붙이고 그 이름을 호출하는 방식으로 실행된다.
스위프트 함수의 일반적인 문법과 호출 방식은 아래와 같다.
// 문법
func 이름(매개변수 이름: 매개변수 타입, ...) -> 반환결과 타입 {
실행부분
return 반환값
}
// 호출 방법
이름()
이름(매개변수 이름: 값, 매개변수 이름: 값)
또한, 스위프트의 함수는 overload(중복 정의)와 override(재정의)를 지원한다.
// overload
func hello() { ... }
func hello(a: Int) { ... }
func hello(a: Int, b: String) { ... }
// override는 다른 섹션에서 다룰 예정이다.
함수와 메서드의 차이
일부에서는 함수를 메서드라고 표현하는 경우가 있는데, 기능적으로는 같은 개념이다.
다만 어디에 정의되어 있느냐에 따라 부르는 이름이 달라진다.
그럼 언제 메서드라고 부를까?
Class, Struct, Enum 같은 타입 내부에 정의된 함수는 메서드라고 부른다.
이때 메서드는 해당 객체의 상태와 함께 동작하는 함수이며, 객체의 행동을 표현하는 요소가 된다.
반대로 타입에 속하지 않고 독립적으로 정의된 함수는 일반 함수라고 부른다.
⸻
2. 매개변수 (Parameter)
이제 함수의 입력값에 해당되는 매개변수에 대해서 설명해보도록 하겠다.
그 전에 사람들이 주로 헷갈려 하는 부분이 있어 이를 먼저 정리해보겠다.
매개변수와 인자
함수에 대한 설명을 보다보면 이 두 단어가 구분 없이 사용되는 경우가 많은데, 엄밀히 따지면 서로 다른 성격을 가진 개념이다.
- 매개변수(parameter) : 명칭. 함수가 입력받는 값에 붙이는 이름
- 인자(argument) : 값. 함수를 호출할 때 실제로 전달하는 값
func add(a: Int, b: Int) -> Int {
return a + b
}
add(a: 1, b: 2)
위 코드를 기준으로 해서 a와 b는 매개변수이며,
add(a: 1, b: 2)처럼 함수를 호출하면서 전달되는 값 1과 2가 인자에 해당한다.
함수인자 레이블
func someFunction(val1: Int, val2: Int) { ... }
someFunction(val1: 1, val2: 2)
Swift의 함수 매개변수는 인자 레이블(argument label) 과 매개변수 이름(parameter name) 을 함께 가질 수 있다.
- 인자 레이블은 함수를 호출할 때 사용하는 이름이다.
- 매개변수 이름은 함수 구현 내부에서 사용하는 이름이다.
기본적으로 매개변수 이름은 인자 레이블로도 함께 사용된다.
즉, 매개변수 이름만 지정하면 자동으로 인자 레이블 역할을 겸하게 된다.
func add(number1 val1: Int, number2 val2: Int) -> Int {
return val1 + val2
}
add(number1: 1, number2: 2)
위 코드처럼 인자 레이블과 매개변수 이름을 따로 지정할 수도 있다.
- number1, number2는 인자 레이블이며
- val1, val2는 매개변수 이름이다.
인자 레이블은 함수 호출 시 사용되고,
매개변수 이름은 함수 내부 구현에서만 사용된다.
func add(_ val1: Int, _ val2: Int) -> Int {
return val1 + val2
}
add(1, 2)
add(val1: 10, val2: 20) // Error
Swift의 함수는 기본적으로 호출할 때 인자 레이블을 함께 표기해야 한다.
하지만 매개변수 앞에 _(언더바)를 붙이면, 해당 매개변수의 인자 레이블을 생략할 수 있다.
이 경우 함수 호출 시 인자 레이블을 명시하면 오히려 컴파일 에러가 발생하니 주의.
func add(val1: Int, val2: Int) -> Int {
return val1 + val2
}
add(val1: 10, val2: 20)
add(val2: 10, val1: 20) // Error
추가로, 매개변수마다 이름이 붙어 있다 보니, 호출 순서가 중요하지 않을 것처럼 느껴질 수 있다.
하지만 실제로 순서에 맞지 않게 호출하면 컴파일 에러가 발생하므로 주의.
기본값
스위프트 함수의 매개변수에는 기본값을 지정할 수 있다.
기본값이 지정된 매개변수는 함수 호출 시 값을 전달하지 않아도 자동으로 해당 기본값이 사용된다.
func greet(name: String = "손님") {
print("안녕하세요, \(name)님")
}
greet() // 안녕하세요, 손님님
greet(name: "철수") // 안녕하세요, 철수님
출력값을 보기위해 사용하는 친숙한 print 함수도 선언을 보면 기본값이 지정된 것을 볼 수 있다.
public func print(_ items: Any..., separator: String = " ", terminator: String = "\n")
가변 매개변수
스위프트 함수는 가변 매개변수를 지원한다.
가변 매개변수는 여러 개의 값을 하나의 매개변수로 받을 수 있도록 해준다.
func sum(_ numbers: Int...) -> Int {
return numbers.reduce(0, +)
}
sum(1, 2, 3, 4)
func someFunc(a: Int..., b: String, c: Float) { ... }
someFunc(a: 1, 2, 3, 4, b: "asdf", c: 1.1)
가변 매개변수는 함수 내부에서 배열처럼 사용되며, 한 함수에서 하나만 사용할 수 있다.
inout
func swapValues(_ a: inout Int, _ b: inout Int) {
let temp = a
a = b
b = temp
}
var x = 10
var y = 20
swapValues(&x, &y)
print(x, y)
// 실행결과
20 10
스위프트의 함수에서 함수가 매개변수 값을 변경하고, 함수 호출이 종료된 후에도 이러한 변경된 값을 유지하고 싶다면,
해당 매개변수를 in-out 매개변수(in-out parameter)로 정의하면 된다.
호출할때는 인자에 &를 붙여서 inout으로 호출되는 인자임을 알려준다.
정리하면 함수에서 매개변수로 전달된 값은 외부에서 복사된 상수 취급이어서 변경할 수가 없다.
하지만 inout 키워드로 그 제한을 우회할 수 있다가 핵심이다.
여기까지는 간단한 문법 설명이고, 깊게 들어가면 inout은 복잡한 매커니즘을 가지고 있다고 한다.
공식문서에서는 copy in, copy out이라는 키워드로 설명하고 있는데
이 부분을 자세히 이해하려면 low level 단계까지 내려가야 하지만, 그러기엔 너무 내용이 복잡해지므로 간단하게만 설명하겠다.
이 구현은 들어다보면 C++의 call by address개념의 형태로 진행된다고 한다. (call by reference가 아니라고 한다)
함수가 호출될 때 전달된 값은 먼저 복사되어 함수 내부로 들어오고(copy-in),
함수가 종료되는 시점에 그 결과가 다시 원본 변수로 되돌려진다(copy-out).
핵심은 inout으로 전달된 매개변수는 주소를 전달받아 처리되는건 맞지만,
직접적으로 해당 주소가 가르키는 값에 접근하여 수정하는 것은 아니라는 점이다.
borrowing, consuming, copy
Swift 5.9부터 borrowing, consuming, copy라는 키워드가 새롭게 추가되었다.
이 키워드들은 Swift의 새로운 소유권(ownership) 모델을 구성하는 핵심 요소로, 값의 생명주기와 복사 여부를 보다 명시적으로 제어할 수 있도록 도입된 문법이다.
기본적으로 스위프트는 함수 호출 전반에 걸쳐 객체의 수명을 자동으로 관리하고, 필요할 때 값을 복사하는 규칙을 사용한다. 이 기본 규칙은 대부분의 상황에서 오버헤드를 최소화하도록 설계되어 있으며, 개발자가 별다른 고민 없이도 안전한 코드를 작성할 수 있도록 돕는다.
위의 내용을 좀 더 풀어서 설명하자면,
스위프트에서 함수가 실행될 때 인자로 전달된 값은 기본적으로 함수 스코프 내로 복사되어 상수로 취급되게 설계되어있다.
그러나 이 과정을 깊이 들여다보면 단순히 값이 복사되어 메모리 공간이 증가하는 것 외에도 여러 부가 작업이 함께 수행되는 것을 볼 수 있다.
인자가 객체라면 ARC 참조 카운트 조정이 발생할 수 있고, 메모리 접근 충돌이 발생하지 않는지에 대한 런타임 검사(exclusivity check)가 수행되기도 한다. 이처럼 컴파일러는 안전성을 보장하기 위해 다양한 방어적 연산을 함께 수행한다.
컴파일러는 컴파일 단계에서 많은 최적화를 시도하지만, 모든 실행 경로를 완벽히 추론할 수는 없다. 따라서 확신할 수 없는 모든 상황에 대해서는 최대한 보수적으로 동작하며, 이는 프로그램의 안정성을 높이기 위한 설계임을 알 수 있다.
이러한 배경 속에서 새롭게 추가된 borrowing, consuming, copy 키워드는 개발자가 값의 소유권을 직접 통제할 수 있도록 하여, 컴파일러가 추론하지 못하는 영역을 대신 보장해 주는 역할을 한다.
즉, “이 값의 생명주기와 사용 방식은 내가 책임지고 보장한다”는 의도를 명시적으로 표현함으로써, 컴파일러가 불필요한 복사나 방어적 검사를 수행하지 않아도 되도록 판단 근거를 제공하는 장치라고 볼 수 있다.
다시 말해, 사람이 컴파일러가 보지 못하는 영역을 대신 책임지고 소유권을 명확히 선언함으로써, 컴파일러가 보다 공격적인 최적화를 수행할 수 있도록 길을 열어주는 문법이라 할 수 있다.
이러한 개념은 Rust의 소유권 모델에서 이미 잘 알려진 개념이며, Swift 역시 이를 부분적으로 도입한 것으로 보인다.
아직 도입된 지 오래되지 않은 문법이기 때문에 일반적인 애플리케이션 코드에서는 자주 사용되는 키워드는 아닌 편이다. 그러다보니 애플 내부 프레임워크나 성능에 민감한 API에서 메모리 오버헤드를 줄이고, 불필요한 복사를 제거하기 위한 최적화 수단으로 활용되고 있지 않을까 싶다.
⸻
3. 반환값과 함수 타입
반환값
앞에서 함수는 입력값을 가공하여 출력값을 호출자에게 전달하는 역할을 한다고 설명했다.
여기서는 함수가 연산한 결과를 어떻게 외부로 전달하는지, 즉 반환값(return value)에 대해 정리해본다.
return
스위프트에서는 '-> 반환타입' 를 함수선언에 표기하여 어떤 값을 반환하는지를 나타내고
함수구문 내에서 return 키워드를 사용하여 반환값을 돌려준다.
반환타입과 값은 옵셔널도 될 수 있고 스위프트의 타입이면 모든지 가능하며,
_ 를 사용해서 반환값을 의도적으로 버릴 수 도 있다.
이런 기본적인 문법 이야기는 다들 잘 알테니 건너뛰고,
반환값이 없는 함수에 대해 잘 알려지지 않은 부분에 대해서만 하나 짚고 넘어가겠다.
func hello() {
print("hello")
}
func hello() -> Void {
print("hello")
return () // 빈 튜플
}
Swift 함수에서 '-> 반환 타입'을 생략하면 반환값이 없는 함수로 선언할 수 있는데
다만 엄밀히 말하면 내부적으로 Void 타입을 반환하는 것이며, 실제 반환값은 ()인 빈 튜플로 처리된다고 한다.
func foo() -> Int {
return 1
}
foo() // 컴파일러에서 경고제공
_ = foo() // _를 사용해서 리턴값을 버림
@discarableresult
func boo() -> Int {
return 1
}
boo() // discarableresult 애트리뷰트를 통해 의도적으로 리턴값을 버린다는 것을 알려줌으로서 컴파일러가 경고를 하지 않도록 넘어가게 해줌
반환 값이 있는 함수의 반환값을 사용하지 않을 경우 컴파일러에서는 경고를 제공하는 데 위에서 설명했다시피 _를 사용해서 반환값을 버려서 경고가 발생하지 않도록 할 수 있다.
func hello() -> String {
return "Hello Swift"
}
func hello() -> String {
"Hello Swift"
}
그리고 함수의 전체 본문이 한 줄로 표현이 된다면, 함수는 해당 표현식을 암시적으로 반환한다.
당연하게도 그 표현식의 타입과 함수 반환타입은 일치해야 한다.
함수 타입
추후 다루겠지만 스위프트에서는 함수도 일급시민이라고 해서 함수 자체가 타입이 될 수 있다.
그래서 함수를 매개변수로도 사용할 수 있고, 리턴 값으로도 사용 할 수 있다.
/* 함수를 타입으로 사용하는 예제 */
func addTwoInts(_ a: Int, _ b: Int) -> Int {
return a + b
}
func multiplyTwoInts(_ a: Int, _ b: Int) -> Int {
return a * b
}
var mathFunction: (Int, Int) -> Int = addTwoInts
print("Result: \(mathFunction(2, 3))")
// 실행결과
"Result: 5"
func printHelloWorld() { print("hello, world") }
// 아무것도 리턴하지 않는 함수를 타입으로 사용할때
var printFunc: () -> Void = printHelloWorld
/* 함수를 매개변수로 사용하는 예제 */
func printMathResult(_ mathFunction: (Int, Int) -> Int, _ a: Int, _ b: Int) {
print("Result: \(mathFunction(a, b))")
}
printMathResult(addTwoInts, 3, 5)
// 실행결과
Result: 8
/* 함수를 리턴 값으로 사용하는 예제 */
func stepForward(_ input: Int) -> Int {
return input + 1
}
func stepBackward(_ input: Int) -> Int {
return input - 1
}
func chooseStepFunction(backward: Bool) -> (Int) -> Int {
return backward ? stepBackward : stepForward
}
while currentValue != 0 {
print("\(currentValue)... ")
currentValue = moveNearerToZero(currentValue)
}
print("zero!")
// 실행결과
3...
2...
1...
zero!
⸻
4. 중첩 함수
스위프트의 함수는 함수내에 또 다른 함수를 정의할 수 있다.
이렇게 정의된 중첩 함수는 기본적으로 외부에서 보이지 않지만, 중첩 함수를 둘러싼 함수를 통해 호출될 수 있고 사용될 수 있다. 중첩 함수를 둘러싼 함수는 중첩 함수 중 하나를 반환하여 중첩 함수를 다른 범위에서 사용할 수도 있다.
func chooseStepFunction(backward: Bool) -> (Int) -> Int {
func stepForward(input: Int) -> Int { return input + 1 }
func stepBackward(input: Int) -> Int { return input - 1 }
return backward ? stepBackward : stepForward
}
var currentValue = -4
let moveNearerToZero = chooseStepFunction(backward: currentValue > 0)
while currentValue != 0 {
print("\(currentValue)... ")
currentValue = moveNearerToZero(currentValue)
}
print("zero!")
// 실행결과
-4...
-3...
-2...
-1...
zero!
⸻
5. 반환하지 않는 함수
func crashAndBurn() -> Never {
fatalError("Something very, very bad happened")
}
앞서 함수는 자신의 구문이 끝나면 값을 반환한다고 설명했다.
반환 값이 없는 함수 역시 내부적으로는 Void 타입을 반환하며, 이는 빈 튜플 ()을 반환하는 것으로 처리된다고 했다.
하지만 Swift에는 이 규칙에서 벗어나는 함수가 하나 존재한다.
바로 반환하지 않는 함수(Functions that Never Return) 이다.
위 코드처럼 Never 타입을 반환 타입으로 가지는 함수가 이에 해당한다.
반환하지 않는다는 것의 의미
그렇다면 “반환하지 않는 함수”란 무엇을 의미할까?
일반적인 함수는 실행이 종료되면 호출 지점으로 되돌아가, 그 다음 코드를 이어서 실행한다.
반면 Never를 반환하는 함수는 정상적으로 종료되지 않으며,
한 번 호출되면 호출한 지점으로 제어 흐름이 다시 돌아오지 않는다는 것을 의미한다.
즉, 함수의 실행이 끝났다는 개념 자체가 성립하지 않는 경우를 타입으로 표현한 것이다.
반환하지 않는 함수는 어떻게 만들어지는가
Never 타입은 사용자가 직접 인스턴스를 생성할 수 없도록 언어 차원에서 막혀 있다.
따라서 사용자는 Never를 “구현”하는 것이 아니라,
이미 제어 흐름을 종료시키는 함수들을 감싸는 방식으로 사용하게 된다.
대표적으로 다음과 같은 함수들이 있다.
• fatalError()
• preconditionFailure()
• abort()
• exit()
앞의 예제에서처럼 fatalError()를 호출하는 방식이 가장 흔하다.
반드시 프로세스 종료만 의미하지는 않는다
반환하지 않는 함수가 반드시 프로세스 종료만을 의미하는 것은 아니다.
다음과 같이 영원히 제어 흐름이 돌아오지 않는 경우에도 Never를 사용할 수 있다.
func forever() -> Never {
while true {
print("I will print forever.")
}
}
이 함수 역시 호출된 이후에는 호출 지점으로 되돌아오지 않으므로,
타입 시스템 관점에서는 Never를 반환한다고 볼 수 있다.
Never가 의미하는 것
어떤 방식이든 Never를 반환하는 함수가 호출된다는 것은,
해당 지점 이후의 코드가 실행될 수 없다는 사실이 명확히 보장된다는 뜻이다.
이는 단순한 문법적 장치가 아니라,
컴파일러에게 제어 흐름에 대한 확실한 정보를 제공하는 역할을 한다.
제어 흐름 분석과 타입 안정성
func example(_ value: Int?) -> Int {
guard let value = value else {
fatalError("value is nil")
}
return value
}
위 코드에서 guard의 else 블록은 Never를 반환하는 fatalError()로 끝난다.
컴파일러는 이를 통해 guard 이후의 코드에서는 value가 nil일 수 없음을 확신할 수 있다.
이 덕분에 불필요한 옵셔널 체크를 제거할 수 있고,
Swift는 타입 안정성을 유지하면서도 보다 공격적인 최적화를 수행할 수 있게 된다.
정리하자면,
Never는 단순히 “특이한 반환 타입”이 아니라,
개발자가 컴파일러에게 제어 흐름을 명확히 선언하는 수단이라고 볼 수 있다.
이 선언을 통해 Swift는 더 안전하고, 더 효율적인 코드를 만들어낼 수 있다.