[Swift 문법] 함수 (Functions)

Yellowtoast·2023년 12월 24일
0

Swift

목록 보기
10/11
post-thumbnail

해당 글은 iOS 스터디를 위한 기본 문법을 정리한 글 입니다.
Advanced Swift (by Chris Eidhof) 책의 내용을 참고하여 작성하였습니다.

Swift에서 함수는 일급 객체(first-class object)로 취급됩니다. 함수와 클로저를 이해하려면 다음 세 가지를 대략 이 순서대로 이해해야 합니다:

  1. 함수는 Int나 String처럼 변수를 할당하고 다른 함수에 인자로 전달할 수 있습니다.
  2. 함수는 로컬 범위 밖에 존재하는 변수를 "캡처"할 수 있습니다.
  3. 함수를 만드는 방법에는 두 가지가 있습니다. - 함수 키워드 또는 클로저 표현식 { }

아래에서 각 세가지를 알아보도록 하겠습니다.

Swift에서의 함수 알아보기

1. 함수는 변수에 할당하거나, 다른 함수의 인자로 전달될 수 있습니다.

많은 현대 프로그래밍 언어와 마찬가지로 Swift에서도 함수를 "일급 객체"로 취급합니다. 따라서 변수에 함수를 할당할 수 있고, 나중에 호출할 다른 함수에 함수를 전달할 수도 있습니다.

이것이 이해해야 할 가장 중요한 사항입니다. 함수형 프로그래밍에서 이것을 "얻는다는 것"은 C에서 포인터를 "얻는다는 것"과 비슷합니다. 이 부분을 제대로 이해할 필요가 있습니다.
정수를 출력하는 함수부터 시작해 보겠습니다.

func printInt(i: Int) {
	print("\(i)를 전달했습니다.")
}

변수에 함수를 할당하기 위해서는, printInt라는 함수의 이름을 사용하여 할당하면 됩니다. 또한 funVar 변수를 사용하여 printInt 함수를 호출할 수 있습니다.

let funVar = printInt
funVar(2) // printInt함수에 2를 전달

또한 printInt(i: 2)와 같이 printInt 호출에는 인자의 이름을 입력해줘야 하는 반면, funVar 호출에는 인자의 이름을 포함하지 않아야 한다는 점도 주목할 만합니다.

Swift는 함수 선언에만 인자 레이블을 허용하며, 레이블은 함수 유형에 포함되지 않습니다. 즉, 현재는 함수 타입의 변수에 인수 레이블을 할당할 수 없지만 향후 Swift 버전에서는 변경될 가능성이 높습니다.

또한, 함수를 인수로 받는 함수를 작성할 수도 있습니다.

func useFunction(function: (Int) -> () ) {
	function(3)
}
useFunction(function: printInt) // 3을 전달했습니다.
useFunction(function: funVar) // 3을 전달했습니다.

함수를 이렇게 다룰 수 있다는 것이 왜 그렇게 중요한가요? Collection 장에서 살펴본 것처럼 함수를 인수로 받아 유용한 방식으로 적용하는 "고차" 함수를 쉽게 작성할 수 있기 때문입니다.

아래와 같이 함수는 다른 함수를 반환할 수도 있습니다.

func returnFunc() -> (Int) -> String {
	func innerFunc(i: Int) -> String {
		return "You passed \(i)." 
    }
	return innerFunc 
 }
let myFunc = returnFunc()
myFunc(3) // 3을 전달했습니다.

2. 함수는 로컬 범위 밖에 있는 변수를 '캡처'할 수 있습니다.

함수가 범위 밖의 변수를 참조하는 경우, 해당 변수는 캡처되어 범위를 벗어나 소멸된 후에도 계속 남아 있습니다.

이를 확인하기 위해 returnFunc 함수를 다시 살펴보고 호출할 때마다 증가하는 카운터를 추가해 보겠습니다.

func makeCounter() -> (Int) -> String {
	var counter = 0
	func innerFunc(i: Int) -> String {
		counter += i // counter is captured
		return "Running total: \(counter)" 
    }
	return innerFunc 
}

일반적으로 makeCounter의 로컬 변수인 counter는 반환문 바로 뒤에 범위를 벗어나 소멸됩니다. 하지만 이 변수는 innerFunc에 의해 캡처되었기 때문에 Swift 런타임은 이를 캡처한 함수가 소멸될 때까지 계속 유지합니다. 내부 함수를 여러 번 호출할 수 있으며 실행 합계가 증가하는 것을 볼 수 있습니다.

let f = makeCounter() 
f(3) // Running total: 3 
f(4) // Running total: 7

makeCounter()를 다시 호출하면 새로운 카운터 변수가 생성되고 캡처됩니다.

let g = makeCounter() 
g(2) // Running total: 2
g(2) // Running total: 4

함수 g에서 벌어진 현상은 첫번째로 할당된 함수인 f에 영향을 미치지는 않습니다.

f(2) // Running total: 9

캡처된 변수와 결합된 이러한 함수는 단일 메서드(함수)와 멤버 변수(캡처된 변수)를 가진 클래스의 인스턴스와 유사하다고 생각하면 됩니다.

프로그래밍 용어로는 함수와 캡처된 변수 환경의 조합클로저라고 합니다. 따라서 위의 f와 g는 클로저의 예시이며, 외부에서 선언된 비로컬 변수(카운터)를 캡처하여 사용하기 때문입니다.

3. 함수는 클로저(Closure)라고 불리는 { } 표현식을 사용하여 선언할 수 있습니다.

Swift에서는 두 가지 방법으로 함수를 정의할 수 있습니다. 하나는 func 키워드를 사용하는 것입니다. 다른 하나는 클로저 표현식을 사용하는 것입니다. 숫자를 두 배로 만드는 간단한 함수를 생각해 봅시다,

func doubler(i: Int) -> Int { returni*2
}
[1, 2, 3, 4].map(doubler) // [2, 4, 6, 8]

다음은 클로저 표현식 구문을 사용하여 작성된 동일한 함수입니다. 이전과 마찬가지로 이 함수를 map으로 전달할 수 있습니다.

let doublerAlt = { (i: Int) -> Int in return i*2 } 
[1, 2, 3, 4].map(doublerAlt) // [2, 4, 6, 8]

클로저 표현식으로 선언된 함수는 1과 "hello"가 정수 및 문자열 리터럴인 것 처럼 함수 리터럴입니다. 또한 이 함수는 func 키워드와 달리 이름이 지정되지 않는 익명의 함수입니다. 익명 함수를 사용할 수 있는 유일한 방법은 변수가 생성될 때 변수에 할당하거나(여기서 doubler를 사용한 것처럼) 다른 함수나 메서드에 전달하는 것입니다.

익명 함수를 사용할 수 있는 세 번째 방법은 함수를 정의하는 동일한 표현식의 일부로 직접 함수를 호출하는 것입니다. 이 방법은 초기화에 한 줄 이상이 필요한 프로퍼티를 정의할 때 유용할 수 있습니다. 프로퍼티 챕터에서 이에 대한 예를 살펴보겠습니다.

클로저 표현식을 사용한 함수와, func 키워드를 사용하여 선언한 함수는 인수 레이블 유무에 대한 차이점을 제외하면 완전히 동일합니다.

그렇다면 { } 구문이 유용한 이유는 무엇일까요? 왜 매번 함수만 사용하지 않을까요? 특히 map과 같이 다른 함수에 전달할 빠른 함수를 작성할 때 훨씬 더 간결할 수 있습니다. 다음은 훨씬 더 짧은 형태로 작성된 함수 예제입니다:

[1, 2, 3].map { $0 * 2 } // [2, 4, 6]

코드를 더 간결하게 만들기 위해 Swift의 여러 기능을 활용했기 때문에 매우 다르게 보입니다.

더 간결하게 만들었기 때문입니다. 하나씩 살펴보겠습니다:

1. 클로저를 인자로 다시 전달할 때 반드시 필요한 경우 먼저 지역 변수에 저장할 필요가 없습니다.

Int를 매개변수로 받는 함수에 5*i와 같은 숫자 표현식을 전달하는 것과 같다고 생각하면 됩니다.

따라서 아래와 같이 선언하여 i 지역변수에 저장할 필요가 없습니다.

/*_*/ [1, 2, 3].map( { (i: Int) -> Int in return i * 2 } ) 

2. 컴파일러가 유형을 유추할 수 있는 경우 유형을 지정할 필요가 없습니다.

이 예제에서 map에 전달된 함수는 배열 요소에서 Int 유형을 유추할 수 있었고, 2가 Int임을 유추할 수 있기 때문에 바로 배열의 요소마다 2를 곱한 결과값을 만들 수 있습니다.

/*_*/ [1, 2, 3].map( { i in return i * 2 } )

3. 클로저 표현식의 몸체에 단일 표현식만 포함되어 있으면 자동으로 표현식 값을 반환하므로 반환을 생략할 수 있습니다.

/*_*/ [1, 2, 3].map( { i in i * 2 } )
  1. Swift는 함수에 대한 인수의 약어 이름을 자동으로 제공합니다(첫 번째는 $0, 두 번째는 $1 등).
/*_*/ [1, 2, 3].map( { $0 * 2 } )

5. 함수에 대한 마지막 인수가 클로저 표현식인 경우 함수 호출의 괄호 밖으로 표현식을 이동할 수 있습니다.

/*_*/ [1, 2, 3].map() { $0 * 2 } 

6. 마지막으로, 함수에 클로저 표현식 이외의 인수가 없는 경우 다음과 같이 할 수 있습니다.

함수 이름 뒤에 괄호를 모두 생략할 수 있습니다.

/*_*/ [1, 2, 3].map{ $0 * 2 }

이러한 각 규칙을 사용하면 아래 표현식을 위에 표시된 형식으로 줄일 수 있습니다:

/*_*/ [1, 2, 3].map( { (i: Int) -> Int in return i * 2 } ) 
/*_*/ [1, 2, 3].map( { i in return i * 2 } )
/*_*/ [1, 2, 3].map( { i in i * 2 } )
/*_*/ [1, 2, 3].map( { $0 * 2 } )
/*_*/ [1, 2, 3].map() { $0 * 2 } 
/*_*/ [1, 2, 3].map{ $0 * 2 }

특정 인수를 무시해야 하는 경우 _를 사용하여 컴파일러에 인수가 있음을 인정하지만 인수가 무엇인지 상관하지 않음을 나타낼 수 있습니다:

(0..<3).map { _ in Int.random(in: 1..<100) } // [26, 57, 48]

변수를 명시적으로 입력해야 하는 경우, 클로저 표현식 내에서 변수를 입력할 필요가 없습니다.
표현식 안에서 할 필요가 없습니다. 예를 들어 유형 없이 isEven을 정의해 보세요:

let isEven = { $0 % 2 == 0 }

위의 경우, Int가 정수 리터럴의 기본 유형이기 때문에 let i = 1이 Int로 추론되는 것과 같은 방식으로 isEven의 유형은 (Int) -> Bool로 추론됩니다.

이는 표준 라이브러리의 유형 별칭인 IntegerLiteralType 때문입니다:

protocol ExpressibleByIntegerLiteral {
	associatedtype IntegerLiteralType
	/// Create an instance initialized to `value`. 		
    init(integerLiteral value: IntegerLiteralType)
}

그러나 다른 유형에 대한 isEven 버전이 필요한 경우 클로저 표현식 안에 인수와 반환 값을 입력할 수 있습니다:

let isEvenAlt = { (i: Int8) -> Bool in i % 2 == 0 }

하지만 Closure 외부에서 타입의 형태를 명시할 수도 있습니다:

let isEvenAlt2: (Int8) -> Bool = { $0 % 2 == 0 } 
let isEvenAlt3 = { $0 % 2 == 0 } as (Int8) -> Bool

물론 모든 정수에 대해 계산된 프로퍼티로 작동하는 isEven의 일반 버전을 정의하는 것이 훨씬 더 좋았을 것입니다:

extension BinaryInteger {
	var isEven: Bool { return self % 2 == 0 }
}

또는 모든 정수 유형에 대해 자유 함수로 isEven 변형을 정의하는 방법을 선택할 수도 있습니다:

func isEven<T: BinaryInteger>(_ i: T) -> Bool { 	 
	return i % 2 == 0
}

변수에 자유 함수를 할당하려면 변수가 작동하는 특정 유형을 잠가야 할 때이기도 합니다. 변수는 일반 함수를 담을 수 없고 특정 함수만 담을 수 있습니다:

let int8IsEven: (Int8) -> Bool = isEven

마지막으로 명명에 대해 한 가지 더 말씀드리겠습니다. func로 선언된 함수는 { }로 선언된 함수와 마찬가지로 클로저가 될 수 있다는 점을 명심해야 합니다. 클로저는 캡처된 변수와 결합된 함수라는 점을 기억하세요. }로 만든 함수를 클로저 표현식이라고 부르지만, 사람들은 종종 이 구문을 그냥 클로저라고 부르기도 합니다. 하지만 클로저 표현식 구문으로 선언된 함수가 다른 함수와 다르다고 생각하거나 혼동하지 마세요. 모든 함수는 함수이며 모두 클로저가 될 수 있습니다.

함수 유연하게 사용하기

내장 컬렉션 장에서 함수를 인수로 전달하여 동작을 매개변수화하는 방법에 대해 설명했습니다. 이에 대한 또 다른 예시인 정렬을 살펴보겠습니다.

Swift에서 컬렉션을 정렬하는 것은 간단합니다.

let myArray = [3, 1, 2] 
myArray.sorted() // [1, 2, 3]

정렬 메서드는 네 가지가 있습니다. 변하지 않는 변형인 sorted(by:)와 변하는 정렬(by:)에 인수를 받지 않고 기본적으로 비슷한 항목을 오름차순으로 정렬하는 버전인 2를 곱한 값입니다. 가장 일반적인 경우에는 sorted()만 있으면 됩니다. 다른 순서로 정렬하려면 함수를 제공하면 됩니다:

myArray.sorted(by: >) // [3, 2, 1]

요소가 Comparable을 따르지 않지만 다음과 같은 경우 함수를 제공할 수도 있습니다.
에는 튜플처럼 < 연산자가 있습니다:

var numberStrings = [(2, "two"), (1, "one"), (3, "three")] 
numberStrings.sort(by: <)
numberStrings // [(1, "one"), (2, "two"), (3, "three")]

또는 임의의 기준에 따라 정렬하려는 경우 더 복잡한 함수를 제공할 수 있습니다:

let animals = ["elephant", "zebra", "dog"] 
animals.sorted { lhs, rhs in
  let l = lhs.reversed()
  let r = rhs.reversed()
  return l.lexicographicallyPrecedes(r)
}
// ["zebra", "dog", "elephant"]

이 마지막 기능, 즉 비교 함수를 사용해 컬렉션을 정렬할 수 있는 기능은 Swift 정렬을 매우 강력하게 만들어 줍니다.
하지만 여러 기준으로 정렬하고 싶다면 어떻게 해야 할까요?
예를 들어 성을 기준으로 정렬한 다음 성이 같으면 이름을 기준으로 정렬하려는 Person 구조체를 생각해 봅시다. 이 섹션에서는 고차 함수를 사용하여 똑같이 유연하고 강력한 자체 SortDescriptor를 다시 구현해 보겠습니다.

먼저 Person 타입을 선언해 보겠습니다.

struct Person {
  let first: String
  let last: String
  let yearOfBirth: Int
}

또한 이름과 생년월일이 다른 여러 사람을 정의해 보겠습니다.

let people = [
  Person(first: "Emily", last: "Young", yearOfBirth: 2002), 
  Person(first: "David", last: "Gray", yearOfBirth: 1991), 
  Person(first: "Robert", last: "Barnes", yearOfBirth: 1985),
  Person(first: "Ava", last: "Barnes", yearOfBirth: 2000), 
  Person(first: "Joanne", last: "Miller", yearOfBirth: 1994),
  Person(first: "Ava", last: "Barnes", yearOfBirth: 1998), 
]

이 배열을 먼저 성, 그다음 이름, 마지막으로 생년월일 순으로 정렬하려고 합니다. 처음에는 성이라는 하나의 키만 기준으로 정렬해 보겠습니다.

잠시 여기서, localizedStandardCompare 함수는 무엇일까요?
공식문서에서 알려주는 해당 함수에 대한 설명을 살펴보면 이와 같습니다. "Compares strings as sorted by the Finder." 그리고 이 함수는 ComparisonResult라는 enum 값을 리턴합니다.

ComparisonResult enum은 3가지로 이루어져 있습니다.

따라서 아래 함수 구문은 p1과 p2의 last값을 비교한 결과가 orderedAscending일 경우, 즉 p1이 더 클 경우 true 값을 리턴합니다. 이 경우 lastName(성) 기준으로 정렬하게 됩니다.

/*_*/people.sorted { p1, p2 in 
	p1.last.localizedStandardCompare(p2.last) == .orderedAscending
}

참고, sort() 함수의 경우 배열을 직접적으로 수정하지만, sorted() 함수의 경우 배열을 복사한 뒤 정렬하여 리턴합니다.

성을 기준으로 비교한 다음 이름을 기준으로 비교하려면 이미 훨씬 더 복잡해집니다.

/*_*/people.sorted { p1, p2 in
switch p1.last.localizedStandardCompare(p2.last) {
  case .orderedAscending:
 	 return true
  case .orderedDescending:
 	 return false
  case .orderedSame:
 	 return p1.first.localizedStandardCompare(p2.first) == .orderedAscending 
  }
}

Functions as Data

생년월일도 포함하기 위해 더 복잡한 함수를 작성하는 대신, 추상화를 도입해 볼 수 있습니다. 표준 라이브러리의 sort(by:) 및 sorted(by:) 메서드는 두 개의 객체를 받아 올바른 순서로 정렬된 경우 참을 반환하는 비교 함수를 사용합니다. 즉, sort를 커스텀 하고 싶을 경우 sort(by: (Int, Int) throws -> Bool) 와 같은 방식으로 구현하면 됩니다.

이 함수에 일반 typealias를 사용하여 SortDescriptor라는 이름으로 정의하였습니다.


typealias SortDescriptor<Root> = (Root, Root) -> Bool

또 다른 대안은 해당 함수를 감싸는 래퍼 구조체를 정의하는 것입니다. 함수를 구조체로 래핑하면 여러 개의 이니셜라이저와 인스턴스 메서드를 정의할 수 있고 코드 완성 기능을 통해 쉽게 찾을 수 있다는 이점이 있습니다.

struct SortDescriptor<Root> {
	var areInIncreasingOrder: (Root, Root) -> Bool
}

아래와 같이 두 Person 값을 생년월일별로 비교하는 SortDescriptor를 정의하거나, 성을 기준으로 정렬하는 SortDescriptor를 정의할 수 있습니다.


let sortByYear: SortDescriptor<Person> = 
	.init { $0.yearOfBirth < $1.yearOfBirth } 
    
let sortByLastName: SortDescriptor<Person> = 
	.init {
    	$0.last.localizedStandardCompare($1.last) ==
        .orderedAscending 
	}

하지만 위의 방법은, 정렬해야 하는 기준이 달라질 때 마다 매번 SortDescriptor를 작성해야 한다는 단점이 있습니다. 또한 개발자의 실수에 따라 잘못하여 비교 속성을 잘못 입력하는 오류를 범할 수도 있겠죠.
그렇다면 특정 속성에 대한 정렬 기능을 제공하는 SortDescriptor를 더 쉽게 작성하려면 어떻게 할 수 있을까요?


extension SortDescriptor {
	init<Value: Comparable>(_ key: @escaping (Root) -> Value) {
		self.areInIncreasingOrder = { key($0) < key($1) }
    }
}

위와 같이 SortDescriptor를 init하는 함수를 작성할 수 있는데요, Comparable한 타입들이라면 비교할 수 있습니다.

키 함수는 Root 유형의 요소를 드릴다운하여 특정 정렬 단계와 관련된 Value 유형의 값을 추출하는 방법을 설명합니다. 이 함수는 Swift의 키 경로와 공통점이 많기 때문에 KeyPath 유형에서 일반 매개변수의 이름인 Root와 Value를 차용했습니다. 이 장의 뒷부분에서 키 경로를 사용해 정렬 설명자를 다시 작성하는 방법에 대해 설명하겠습니다.

이제 아래와 같이 새 이니셜라이저를 사용하여 sortByYear SortDescriptor를 정의할 수 있습니다.


let sortByYearAlt: SortDescriptor<Person> = .init { $0.yearOfBirth }

마찬가지로, 현지화된 표준 비교와 동일한 형태의 함수 및 비교 결과를 반환하는 다른 파운데이션 메서드가 있는 경우 이니셜라이저를 만들 수 있습니다. String 부분을 제네릭으로 만들면 이니셜라이저는 다음과 같이 보입니다.

extension SortDescriptor {
	init<Value>(_ key: @escaping (Root) -> Value,
		by compare: @escaping (Value) -> (Value) -> ComparisonResult) {
     self.areInIncreasingOrder = {
		compare(key($0))(key($1)) == .orderedAscending 
        }
     } 
 }

표현식 String.localizedStandardCompare의 유형을 확인하면 (String) -> (String) -> ComparisonResult라는 것을 알 수 있습니다. 무슨 일이 벌어지고 있는 걸까요? 내부적으로 인스턴스 메서드는 인스턴스가 주어지면 인스턴스에서 작동하는 다른 함수를 반환하는 함수로 모델링됩니다. 일부 문자열은 실제로 String.localizedStandardCompare(일부 문자열)를 작성하는 또 다른 방법일 뿐입니다. 두 표현식 모두 (문자열) -> ComparisonResult 유형의 함수를 반환하고 이 함수는 일부 문자열을 캡처한 클로저입니다.

이를 통해 매우 간결한 방식으로 sortByFirstName을 작성할 수 있습니다:

let sortByFirstName: SortDescriptor<Person> =
.init({ $0.first }, by: String.localizedStandardCompare)

여러 속성을 기준으로 정렬하려는 경우 두 개의 정렬 설명자를 하나로 결합할 수 있습니다. 먼저 기본 정렬 설명자를 사용하여 비교한 다음, 값이 증가 또는 감소 순서가 아닌 경우 두 번째 정렬 설명자의 결과를 사용할 수 있습니다:

extension SortDescriptor {
	func then(_ other: SortDescriptor<Root>) -> SortDescriptor<Root> {
		SortDescriptor { x, y in
			if areInIncreasingOrder(x,y) { return true }
            if areInIncreasingOrder(y,x) { return false }
			return other.areInIncreasingOrder(x,y) 
        }
     } 
  }

이렇게 하면 세 가지 정렬 설명자를 모두 결합하는 단일 정렬 설명자로 연결할 수 있습니다:

let combined = sortByLastName.then(sortByFirstName).then(sortByYear)
people.sorted(by: combined.areInIncreasingOrder)
/*
[Person(first: "Ava", last: "Barnes", yearOfBirth: 1998),
Person(first: "Ava", last: "Barnes", yearOfBirth: 2000), 
Person(first: "Robert", last: "Barnes", yearOfBirth: 1985), 
Person(first: "David", last: "Gray", yearOfBirth: 1991), 
Person(first: "Joanne", last: "Miller", yearOfBirth: 1994), 
Person(first: "Emily", last: "Young", yearOfBirth: 2002)]
*/

저희 솔루션은 아직 Foundation의 정렬 기술자만큼 표현력이 뛰어나지는 않지만, Swift의 모든 값에서 작동하며 NSObject뿐만 아니라 모든 값에서 작동합니다. 또한 런타임 프로그래밍에 의존하지 않기 때문에 컴파일러가 코드를 훨씬 더 잘 최적화할 수 있습니다.

함수 기반 접근 방식의 한 가지 단점은 함수가 불투명하다는 것입니다. NSSortDescriptor를 가져와서 콘솔에 출력하면 키 경로, 선택기 이름, 정렬 순서 등 정렬 설명자에 대한 몇 가지 정보를 얻을 수 있습니다.

NSSecureCoding을 사용하여 NSSortDescriptor를 직렬화 및 역직렬화할 수도 있습니다. 함수 기반 접근 방식은 이 작업을 수행할 수 없습니다.

하지만 함수를 데이터로 사용하여 배열에 저장하고 런타임에 해당 배열을 빌드하는 접근 방식은 새로운 차원의 동적 동작을 열어주며, 이는 Swift와 같이 정적으로 타입이 지정된 컴파일 시간 지향 언어가 Objective-C나 Ruby와 같은 언어의 일부 동적 동작을 복제할 수 있는 한 가지 방식입니다.

또한 함수형 프로그래밍의 기본 요소 중 하나인 다른 함수를 결합하는 함수 작성의 유용성도 확인했습니다. 예를 들어, 당시의 메서드는 두 개의 정렬 설명자를 하나의 정렬 설명자로 결합했습니다. 이는 다양한 용도로 활용할 수 있는 매우 강력한 기술입니다.

profile
Flutter App Developer

0개의 댓글