2021.1.20 TIL

함수형 프로그래밍을 하며 생각한 것

  1. 순수함수가 무엇인지 대하여 꽤나 고민을 했었다. 동일한 입력을 넣으면 동일한 출력을 하는 함수로 생각을 했었는데, 이런 함수라도 사이드 이펙트가 있을 수 있기에 좀 더 구체적인 순수함수의 정의에 대하여 생각하며 내린 결론은 "매개변수에만 의존하는 값을 반환하는 함수" 라고 생각을 했다. 전역변수(싱글톤, 비지역변수(클래스,구조체의 속성))에 액세스하지 않는 것이 순수함수라고 한다. 또한 사이드이펙트가 없어야 한다. 함수 밖의 상태를 변경하지 않고 값만 반환해야 한다.

    즉, 순수함수란 함수의 매개변수에만 의존하는 값을 반환하되 함수 밖의 상태를 변경하지 않는 함수

라고 생각한다.

  1. 함수형 프로그래밍은 선언적이다?!
    이 말이 의미를 이해하기가 굉장히 어려웠다. 이 말의 의미를 고민 끝에 "프로그램이 작동하는 방식이 아니라 수행하는 작업에 초점을 맞춘다"로 결론을 지었다. 즉, 함수를 간결하고 추상화가 잘 되도록 만들자라는 뜻으로 생각했다.

  2. 왜 함수형 프로그래밍을 사용하는가?
    함수형이든 객체지향이든 Swift 프로그램을 작성하는데에 아무런 문제가 없다. 통계를 직접적으로 보지는 않았지만, 함수형 패러다임을 사용하는 개발자는 소수일 것이라고 생각한다. 하지만 분명한 장점이 있기에 사용할 것이다. 순수함수의 장점을 생각해보자면,

  • 순수함수는 테스트하기가 간단하다. 하지만 사이드이펙트를 생성하는 코드를 테스트하려면 다른 기술이 필요로 하기에 테스트가 비교적 까다롭다.

  • 순수함수는 때때로 읽고 이해하기가 더 쉽다. 순수함수로 작성된 함수는 내부 구현에 대하여 걱정할 필요가 없이 반환값에 대해서만 신경을 쓰면 된다. 그렇기에 함수 시그니처나 어떤 입력에 대한 반환값을 확인해보면 된다.

  • 순수함수는 동시성(concurrency)을 단순화한다. 싱글톤이나 비지역번수에 액세스하지 않기 때문에 경쟁 상태(race condition: 둘 이상의 입력 또는 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태)을 유발할 위험이 없다. 병렬 스레드에서 여러 위험요소를 피할 수 있다.

하지만 Swift언어 자체가 멀티 패러다임을 지원하는 언어이고 대다수의 개발자가 OOP, POP를 더 많이 사용하기 때문에 모든 함수를 순수하게 만다는 것은 불가능하다. 특히 iOS개발에서 viewcontroller 코드를 함수형으로 작성하는 것은 매우 힘들다. 하지만 비지니스 로직을 작성할 때 함수형으로 작성하면 테스트하기도 좋은 코드를 작성할 수 있을 것이다.

함수형 프로그래밍의 핵심 아이디어1

순수함수가 무엇이고 사용하면 어떤 점이 좋은지 생각해보았다. 이러한 것들이 실제로 어떻게 사용하는지 함수형 프로그래밍의 핵심 아이디어를 알아보았다. 함수형 프로그래밍은 다른 패러다임과 비교했을 때에 다형성(OOP)대신에 추상화를 위해 "고차함수"를 사용하고 반복을 위해 함수를 Mapping, Reduce, filter를 사용한다.

  • 고차함수를 사용하여 코드 추상화하기

어떤 프로그램에서든 반복을 피하기 위해 종종 코드 재사용을 해야한다. 그 중 함수는 코드를 재사용하는 기본 방법이다. 코드를 재사용 할 때 가능한 광범위한 사례를 수용하기 위해 코드를 추상적으로 만드는게 좋다. 하지만 함수만으로 추상화 매커니즘이 충분하지 않다. Swift의 객체지향 또는 프로토콜 지향 프로그래밍에서는 다형성을 통해 코드를 추상화한다.

하위 타입이 상속할 수 있도록 메서드를 슈퍼클래스 또는 프로토콜로 이동하며 또는 함수의 매개변수에 대한 타입으로 슈퍼클래스, 프로토콜 및 제네릭을 사용한다. 하지만 함수형 프로그래밍에서는 클래스나 프토토콜이 없다. 추상화 가능한 유일한 기능은 "함수"이다. 이렇게 함수만으로 추상화를 시켜야 하기 때문에 고차함수를 사용하여 코드를 작성한다.

일반적으로 함수를 인수로 취하거나 함수를 반환하는 함수를 고차함수라 한다. 고차함수는 두 함수를 결합하여 새로운 함수를 만들 수도 있다. Swift에는 이미 여러 고차함수를 제공한다.

예를 들어,
URLSession의 dataTask(with : completionHandler) 메서드는 고차함수이다. completionHandler는 네트워크 전송이 완료되면 호출되는 함수로 datsTask함수 안에 함수를 인수로 받는 경우이다.

또한

map, filter, reduce 라는 세가지의 중요한 고차함수가 있다.

filter

filter는 시퀀스에서 요소를 제거한다.

let numbers = [1, 9, 7, 2, 5, 4, 6]
 
numbers.filter { $0 > 3 }
// returns [9, 7, 5, 4, 6]
 
numbers.filter { $0.isMultiple(of: 2) }
// returns [2, 4, 6]

filter는 배열이나 문자열에서 사용 가능하다. 예를 들어, String에서 문자와 기호를 따로 추출해내거나 하는 작업에서 filter 가 쓰일 수 있다. 하지만 filer는 단순히 눈속임일지도 모른다. filter 안에 while 루프가 숨겨져 있기 때문이다. 굳이 눈속임을 해서라도 고차함수를 써야할까? 를 고민해보며 다른 고차함수를 살펴본다.

@inlinable
public __consuming func filter(
	_ isIncluded: (Element) throws -> Bool
	) rethrows -> [Element] {
	return try _filter(isIncluded)
}
 
@_transparent
public func _filter(
	_ isIncluded: (Element) throws -> Bool
	) rethrows -> [Element] {
	var result = ContiguousArray<Element>()
	var iterator = self.makeIterator()
	while let element = iterator.next() {
		if try isIncluded(element) {
			result.append(element)
		}
	}
	return Array(result)
}

Map

map은 시퀀스의 각 요소에 변환 함수를 적용한 결과가 포함된 배열을 반환한다.

func square(x: Int) -> Int {
	return x * x
}
 
let numbers = [1, 9, 7, 2, 5, 4, 6]
 
numbers.map(square)
//returns [1, 81, 49, 4, 25, 16, 36]
  • Map 예시 코드

동물을 입력 값으로 넣었을 때, 해당 동물의 울음 소리를 반환하는 코드를 함수형으로 짜고 싶었다.

struct CryMaker {}

private extension CryMaker {
    func reflect(animal: String) -> String {
        return StaticData.cryingData[animal] ?? animal
    }
}

struct StaticData {}

extension StaticData {
    static var cryingData: [String: String] {
        return [
            "강아지": "멍멍",
            "고양이": "야옹",
            "돼지": "꿀꿀",
            "소": "음메",
            "호랑이": "어흥",
        ]
    }
}

StaticData의 cryingData는 동물value에 대한 울음소리 key로 이루어진 사전이고, CryMaker에서 reflect함수를 통해 "강아지"를 입력하면 "멍멍이" 출력되도록 만들었다.
하지만 "강아지 고양이 호랑이"와 같이 띄어쓰기로 구분하여 여러 동물의 울음소리를 반환하고 싶어서 map함수를 사용하기로 했다.

struct CryMaker {
    func reflect(animals: String) -> String {
        return animals
            .components(separatedBy: .whitespaces)
            .map (reflect(animal:))
            .joined(separator: " ")
    }
}

우선 whiteSpace 를 구분으로 string배열로 만들고 map고차함수를 사용하여 각 요소(동물이름)을 변환(울음소리)하고 whiteSpace를 기준으로 다시 합쳐서 String으로 만들었다. 고차함수를 사용하여 만든 숨수함수인 줄 알았지만 eflect(animal:) 함수는 순수함수가 아니었다. animal 매개변수뿐만 아니라 StaticData cryingData 속성에도 의존하기 때문이다. 이 함수를 순수함수로 만들기 위한 방법으로 약간의 코드 수정을 했다.

struct CryMaker {
    func reflect(animals: String) -> String {
        return animals
            .components(separatedBy: .whitespaces)
            .map { reflect(animal: $0, with: StaticData.cryingData) }
            .joined(separator: " ")
    }
}

private extension CryMaker {
    func reflect(animal: String, with cryingData: [String: String]) -> String {
        return cryingData[animal] ?? animal
    }
}

cryingData 사전은 reflect(animal:)의 또 다른 매개변수로 바꿨다. 이 함수와 마찬가지로 reflect(animals:)함수도 reflect를 의존하고 있기 때문에 매개변수로 돌릴 수 있음을 느꼈다. 그러나 어느 시점에서는 순수하지 않은 함수의 경우가 분명히 생길 수 있다는 것을 느꼈고, 그것이 멀티 패러다임 언어인 swift/ ios의 특성인 것 같다.

reduce

reduce함수는 시퀀스의 요소를 단일 값으로 결합한다.
reduce함수의 동작은

조합을 시작할 초기 값을 취하고,
두 값을 세번째 값으로 결합하는 함수를 사용.
시퀀스의 요소를 반복하고 현재 요소를 이전 조합의 결과와 결합한다. 고차함수 중에 가장 사용하기 어렵고 덜 일반적인 함수라고 생각하지만 꼭 필요로 할 때가 있다..

let numbers = [1, 9, 7, 2, 5, 4, 6]
 
numbers.reduce(0, +)
// returns 34
 
numbers.reduce(1, *)
// returns 15120
 
let booleans = [true, false, true, true, false]
 
booleans.reduce(true, { $0 && $1 })
// returns false
 
booleans.reduce(false, { $0 || $1 })
// returns true

초기 값은 배열 요소를 결합하는 방식에 따른다. 배열 요소를 결합하는 방식이 + 라면 초기값은 0이 돼야 하고 * 라면 초기값은 1이 돼야 한다. boolean의 경우 &&연산자로 결합하는 경우에는 두 값이 true인 경우에만 true를 반환하기 때문에 초기값이 true이고 || 연산자로 결합하는 경우에는 값 중 하나 이상이 true이면 true를 반환하기 때문에 초기값이 false이다.

reduce(_:) 함수의 초기값은 항상 위와 같이 간단하지는 않다. 예를 들어 단어를 연결하려는 경우 빈 문자열에서 시작하는 것은 단어 사이에 공백이 필요하지 않는 경우에만 가능하다.

struct CryMaker {
    func reflect(animals: String) -> String {
        let reflected = animals
            .components(separatedBy: .whitespaces)
            .map { reflect(animal: $0, with: StaticData.cryingData) }
        
        return reflected
            .dropFirst()
            .reduce("\(reflected[0])", { $0 + " " + $1 })
    }
}

문자열의 시작에 공백을 두지 않기 위해 reduce의 초기값이 시퀀스의 첫번째 요소로 만들어준다. 즉, 시퀀스의 나머지 요소에 대해서만 reduce를 호출해야 한다.

함수형 프로그래밍의 핵심 아이디어2 : 재귀함수

함수형 프로그래밍의 핵심 중 하나는 "loop" 없이 프로그램을 작성하는 것이다. 재귀함수를 사용하여 그것을 가능하게 한다. 재귀를 사용하면 문제를 같은 유형의 더 작은 문제로 분할한 다음 결과를 결합하여 문제를 해결한다. 예를 들어,

5!  = 5 * 4 * 3 * 2 * 1  // 120

위 글을 코드로 바꾸면 루프를 사용하여 작성할 수 있다. 그러나 재귀 관점에서 문제를 살펴보면

5! = 5 * 4!

이렇게 같은 유형의 문제를 더 작은 문제로 분할한 형태로 나타낼 수 있다.

func factorial(of number: Int) -> Int {
	if number == 0 {
		return 1
	}
	return number * factorial(of: number - 1)
}

재귀로 나타낸 5! = 5 * 4! 이다. factorial 함수로 보는 재귀함수의 특징은 기본 값이 있어야 한다는 것이다. 위 함수의 기본 값은 0이다. 0! = 1은 변하지 않는 케이스이기 때문이다. 또 다른 예로,

func double(of numbers: [Int]) -> [Int] {
	if numbers.isEmpty {
		return []
	}
	let first = numbers[0] * 2
	return [first] + double(of: Array(numbers.dropFirst()))
}
 
double(of: [1, 2, 3, 4, 5])
// retunrs [2, 4, 6, 8, 10]

숫자 배열을 받아 두 배로 만드는 함수를 작성할 때 기본 값은 빈 배열이다. 빈 배열을 두배로 들리면 도 빈 배열이 생성되기 때문에 변하지 않는 케이스를 기본 값으로 둔다. 숫자 배열을 두 배로 늘리는 것은 첫번째 요소를 두 배로 늘리고 나머지 배열 요소를 두 배로 늘리고 값을 합친다.

결론


함수형 프로그래밍을 사용하는 이유는 코드 가독성, 테스트 용이성, 동시성에 장점이 있다. 따라서 Swift는 함수형 언어는 아니지만 코드의 일부를 함수형으로 작성하면 분명히 좋을 것이다.

그러나 모든 코드를 함수형으로 작성하는 것은 힘들다. 그리고 코드의 가독성은 함수형이 아닌 상황에 더 좋을 수 있고, 비생산적일 수 있다. 그리고 함께 일하는 개발자가 익숙하지 않은 경우가 있다는 것을 알아야 한다. 나 역시도 아직 익숙하지 않지만 앱 비지니스 로직을 작성할 때에 함수형으로 작성하는 훈련을 잘 해두면 나중에 협업할 때 좋을 것 같다는 생각이 든다.

좋은 웹페이지 즐겨찾기