7.3.1 일급 함수의 특성

객체가 다음의 조건을 만족하는 경우 이 객체를 일급 객체로 간주합니다

  1. 객체가 런타임에도 생성이 가능해야 한다

  2. 인자값으로 객체를 전달할 수 있어야 한다

  3. 반환값으로 객체를 사용할 수 있어야 한다

  4. 변수나 데이터 구조 안에 저장할 수 있어야 한다

  5. 할당에 사용된 이름과 관계없이 고유한 구별이 가능해야 한다

함수가 이런 조건을 만족하면 이를 일급 함수(First-Class Function)라고 하고 그 언어를 함수형 언어로 분류합니다. 즉 함수형 언어에서는 함수가 일급 객체로 대우받는다는 뜻입니다.

함수가 일급 객체로 대우받는다면 런타임에도 함수의 생성이 가능하고, 매개변수나 반환값으로 함수를 전달할 수 있으며, 함수를 변수나 데이터 구조 안에 저장할 수 있을 뿐만 아니라 함수의 이름과 관계없이 고유한 구별이 가능합니다. 이것들이 일급 객체가 되기 위한 조건이기 때문입니다. 지금부터 일급 함수의 특성에 대해 살펴보겠습니다.

일급 함수의 특성 1 - 변수나 상수에 함수를 대입할 수 있음

변수나 상수에 함수를 대입한다는 것은 함수 자체를 변수에 집어넣는다는 뜻입니다. 예제를 보겠습니다

// 정수를 입력받는 함수
func foo(base: Int) -> String {
    return "결과값은 \(base + 1)입니다"
}

let fn1 = foo(base: 5)
// 결과값은 6입니다

이 예시는 단지 함수의 결과값을 변수에 담은 것이지, 일급 함수에서 말하는 변수나 상수를 함수에 대입한다는 예시는 아닙니다. 다음 예시를 보겠습니다.

let fn2 = foo // fn2 상수에 foo 함수가 할당됨
fn2(5) // 결과값은 6입니다

상수 fn2에 foo 함수를 대입하고 있습니다. 함수 자체가 대입되었으므로 이제 fn2는 foo와 이름만 다를 뿐 같은 기능을 하는 함수가 됩니다.

변수나 상수에 함수를 대입할 때에는 함수가 실행되는 것이 아니라 함수라는 객체 자체만 대입됩니다. 다음 예제를 봅시다.

func foo(base: Int) -> String {
    print("함수 foo가 실행됩니다")
    return "결과값은 \(base + 1)입니다"
}

앞서 작성했던 함수 foo에서 내부에 출력 구문을 추가했습니다. 우선 이 함수의 결과값을 상수에 할당해보겠습니다.

let fn3 = foo(base: 5)

// 실행 결과
"함수 foo가 실행됩니다"

이처럼 함수의 결과값을 대입할 때는 함수가 실행됩니다. 하지만 함수 자체를 대입하는 구문은 다릅니다. 함수 자체를 대입하는 구문을 살펴봅시다.

let fn4 = foo
// 출력 결과 없음
fn4(7)

// 실행 결과
"함수 foo가 실행됩니다"

상수 fn4에 foo함수를 대입하는 과정에서는 아무런 값도 출력되지 않습니다. foo 함수가 실행되지 않았다는 뜻입니다. fn4에 인자값 7을 넣어 함수를 실행하면 그때서야 메시지가 출력됩니다. foo 함수가 실행되었다는 것을 알 수 있습니다.

함수를 대입하기 위해 알아야 할 것이 있습니다. 바로 타입(Types)입니다. 변수에 함수를 대입하면 그 변수는 지금까지 배운 것들과는 다른 타입이 되는데 이 타입을 함수 타입이라고 합니다.

함수 타입은 함수의 형태를 축약한 형태로 사용하는데, 이때 함수의 이름이나 실행 내용 등은 함수 타입에서는 아무런 의미가 없으므로 생략할 수 있습니다. 함수 타입에서 필요한 것은 단지 어떤 값을 입력받는지와 어떤 값을 반환하는지 뿐입니다.

(인자 타입1, 인자 타입2, ...) -> 반환 타입

실제 함수를 보면서 함수 타입에 대해 알아봅시다.

func boo(age: Int) -> String {
    return "\(age)"
}

이를 함수 타입으로 바꾸면 다음과 같습니다.

(Int) -> String

이 함수를 상수에 할당한다면 이 상수의 타입 어노테이션을 포함한 할당 구문은 다음과 같습니다.

1. let fn: (Int) -> String = boo
2. let fn: (Int) -> String = boo(age:)

boo는 함수의 이름, boo(age:)는 함수의 식별자입니다. 함수의 대입 구문을 작성할 때에는 함수의 이름이나 함수의 식별자 어느 것을 사용해도 됩니다. 그런데 1번의 경우 다음과 같은 경우에 문제가 생길 수 있습니다.

func boo(age: Int) -> String {
    return "\(age)"
}

func boo(age: Int, name: String) -> String {
    return "\(name)의 나이는 \(age)입니다"
}

let t = boo (X)

이러면 t에 대입되는 함수는 첫 번째일까요, 혹은 두 번째일까요? 정확히 어느 것을 가리키는지 판단할 수 없으므로 오류가 발생합니다. 이를 해결하려면 두 가지 중 하나로 바꾸어야 합니다.

// 해결 방법 1 : 타입 어노테이션을 통해 입력받을 함수의 타입을 지정
let t1: (Int, String) -> String = boo

// 해결 방법 2 : 함수의 식별값을 통해 입력받을 정확한 함수를 지정
let t2 = boo(age:name:)

다음과 같이 타입 어노테이션을 적절히 사용하면 같은 함수 이름을 사용하여 대입하더라도 서로 다른 결과를 가져오기도 합니다.

let fn01: (Int) -> String = boo // boo(age:)
let fn02: (Int, String) -> String = boo // boo(age:name:)

두 케이스 모두 boo라는 함수를 할당받고 있습니다. 하지만 타입 어노테이션의 차이로 인해 각각 다른 함수가 대입됩니다. 이처럼 동일한 함수 이름을 사용하더라도 타입 어노테이션에 의해 대입되는 함수가 달라지기도 하므로 주의해야 합니다.

타입 어노테이션과 함수 이름의 조합으로 대입 구문을 구성하면 안 되는 경우도 있습니다. 동일한 함수 타입을 사용하지만 매개변수명이 서로 다른 함수의 경우가 이에 해당합니다.

func boo(age: Int, name: String) -> String {
    return "\(name)의 나이는 \(age)입니다"
}

func boo(height: Int, nick: String) -> String {
    return "\(nick)의 키는 \(height)입니다"
}
let fn03: (Int, String) -> String = boo 
let fn04: (Int, String) -> String = boo 

인자값과 반환값에 따른 함수 타입이 (Int, String) -> String로 동일하므로 타입 어노테이션만으로 함수를 특정하기 어렵습니다. 이같은 부정확성에 따라 컴파일러는 오류를 발생시킵니다. 오류를 피하기 위해, 함수의 이름이 아니라 식별자를 사용하여 정확하게 구분해야 합니다.

let fn03: (Int, String) -> String = boo(age:name:)
let fn04: (Int, String) -> String = boo(height:nick:)

다시 함수 타입으로 돌아가 봅시다. 몇 가지 특별한 형태의 함수 타입을 보겠습니다.

먼저 튜플을 반환값으로 반환하는 함수의 타입은 다음과 같습니다.

func boo(age: Int, name: String) -> (String, Int) {
    return (name, age)
}

// 위 함수의 타입
(Int, String) -> (String, Int)
func boo() -> String {
    return "empty values"
}

// 위 함수의 타입
() -> String
func boo(base: Int) {
    print("param = \(base)")
}

// 위 함수의 타입
(Int) -> ()
func boo() {
    print("empty values")
}

// 위 함수의 타입
() -> ()

함수 타입을 표시할 때 반환값이 없는 경우 빈 괄호 대신 Void를 사용하여 값이 없음을 표현하기도 합니다. Void는 빈 튜플을 나타내는 키워드입니다.

public typealias Void = ()

Void를 적용해보면 함수 타입을 아래와 같이 표현 가능합니다.

(Int) -> ()  =>  (Int) -> Void
() -> ()  =>  () -> Void 

Void 키워드는 본래 빈 인자값의 표현에도 사용할 수 있었으나 4.0 버전부터는 반환 타입에만 사용할 수 있도록 제한되었습니다. 따라서 위의 두 번째 예제에서 인자 타입 ()는 그대로 ()로 사용됩니다.

일급함수의 특성 2 - 함수의 반환 타입으로 함수를 사용할 수 있음

일급 함수의 두 번째 특성은 함수의 반환 타입으로 함수를 사용할 수 있다는 특성입니다. 다음 예제를 보겠습니다.

func desc() -> String {
    return "this is desc()"
}

func pass() -> () -> String {
    return desc
}

let p = pass()
p() // "this is desc()"

복잡해 보일 수 있지만 잘 따라가 보면 코드를 이해할 수 있습니다. pass 함수를 실행한 결과값은 desc 함수이고 결국 p라는 변수에는 desc 함수 자체가 담기게 됩니다. 즉 p()는 p 함수를 실행한 것이므로 desc()와 동일하여 "this is desc()"가 출력됩니다.

이번에는 조금 복잡한 예제를 살펴보겠습니다

func plus(a: Int, b: Int) -> Int {
    return a + b
}

func minus(a: Int, b: Int) -> Int {
    return a - b
}

func times(a: Int, b: Int) -> Int {
    return a * b
}

func divide(a: Int, b: Int) -> Int {
    guard b != 0 else {
        return 0
    }
    return a / b
}

func calc(_ operand: String) -> (Int, Int) -> Int {
    switch operand {
    case "+" :
        return plus
    case "-" :
        return minus
    case "*" :
        return times
    case "/" :
        return divide
    default :
        return plus
    }
}

위 4개의 함수들은 사칙연산을 구현한 함수들입니다. 그런데 마지막으로 작성된 calc는 조금 다릅니다. 이 함수는 사칙연산의 연산자를 문자열 형식으로 입력받습니다. 반환하는 함수의 타입 표현식은 (Int, Int) -> Int 입니다. 함수를 실행해보겠습니다.

let c = calc("+")
c(3,4) // plus(3,4) = 7

let c2 = calc("-")
c2(3,4) // minus(3,4) = -1

let c3 = calc("*")
c3(3,4) // times(3,4) = 12

let c4 = calc("/")
c4(3,4) // divide(3,4) = 0

이처럼 함수의 실행 결과로 다른 함수를 반환할 수 있는 것이 일급 함수의 특성입니다. 이 특성은 중첩 함수를 학습할 때 다시 다룹니다

일급함수의 특성 3 - 함수의 인자값으로 함수를 사용할 수 있음

일급 함수는 반환값으로 함수를 사용할 수 있을 뿐만 아니라 다른 함수의 인자값으로 함수를 전달할 수 있는 특성도 있습니다. 다음은 함수를 인자값으로 전달하는 예제입니다.

func incr(param: Int) -> Int {
    return param + 1
}

func broker(base: Int, function fn: (Int) -> Int) -> Int {
    return fn(base)
}

broker(base: 3, function: incr) // 4

incr(param:)은 정수값을 입력받아 +1 처리한 후 값을 반환하는 합수입니다. broker(base:function:)은 인자로 받은 함수를 실행하는 함수입니다. function이라는 파라미터는 정수를 파라미터로 받아 정수를 리턴하는 함수입니다. broker(base:function:) 함수의 정의 구문만으로는 어떤 연산이 실행될지 짐작하기 어렵습니다. 실질적인 연산은 인자로 받는 함수에 달려 있기 때문입니다. 이런 식으로 중개 역할을 하는 함수를 브로커(Broker)라고 합니다.

이번에는 콜백 함수를 사용하는 예를 살펴봅시다.

func successThrough() {
    print("연산 처리가 성공했습니다")
}

func failThrough() {
    print("처리 과정에 오류가 발생했습니다")
}

func divide(base: Int, success sCallBack: () -> Void, fail fCallBack: () -> Void) -> Int {
    guard base != 0 else {
        fCallBack() // 실패 함수를 실행한다
        return 0
    }
    
    defer {
        sCallBack() // 성공 함수를 실행한다
    }
    return 100 / base
}

divide(base: 30, success: successThrough, fail: failThrough)
// 실행 결과
연산 처리가 성공했습니다

처음 보는 defer라는 키워드가 등장했습니다. defer 블록은 함수나 메서드에서 코드의 흐름과 상관없이 가장 마지막에 실행되는 블록입니다. 지연 블록이라고도 합니다. 작성된 위치에 상관없이 항상 함수의 종료 직전에 실행되기 때문에, 종료 시점에 맞추어 처리해야 할 구문이 있다면 defer 블록 안에 넣으면 됩니다. defer 블록은 다음과 같은 특성이 있습니다.

  1. defer 블록은 작성된 위치와 순서에 상관없이 함수가 종료되기 직전에 실행된다

  2. defer 블록을 읽기 전에 함수의 실행이 종료될 경우 defer 블록은 실행되지 않는다

  3. 하나의 함수나 메서드 내에서 defer 블록을 여러 번 사용할 수 있다. 이때에는 가장 마지막에 작성된 defer 블록부터 역순으로 실행된다

  4. defer 블록을 중첩해서 사용할 수 있다. 이때에는 바깥쪽 defer 블록부터 실행되며 가장 안족에 있는 defer 블록은 가장 마지막에 실행된다

divide(base: 30, success: successThrough, fail: failThrough)

이 구문을 다시 살펴보면 인자값으로 사용하기 위해 새로운 성공/실패 함수를 작성해야 하는 것은 번거롭습니다. 이런 문제를 해결하고자 많은 함수형 언어에서는 익명 함수를 지원합니다. 쉽게 생각해서 일회용 함수라고 생각하면 됩니다.

스위프트에서도 익명 함수를 지원합니다. 이를 클로저(Closure)라고 합니다. 클로저에 대해서는 뒤에서 자세히 배우겠습니다. 이번에는 위 예제의 호출 부분을 익명 함수로 이용하여 작성해 보는 것으로 마무리하겠습니다.

divide(base: 30, 
       success: {
              () -> Void in
              print("연산 처리가 성공했습니다")       
       }, 
       fail: {
              () -> Void in 
              print("처리 과정에 오류가 발생했습니다")       
       }
)

이러한 구문은 클로저를 알아야 이해할 수 있으므로 가볍게 보고 넘어가시면 됩니다.

Last updated