개발/코틀린

(코틀린) 함수형 프로그래밍

DinoDev 2022. 10. 10. 21:40
728x90
반응형

코틀린을 활용한 함수형 프로그래밍

함수형 프로그래밍은 프로그램 코드를 불변 값을 변환하는 함수의 합성으로 구성할 수 있는 아이디어를 바탕으로 합니다.

함수형 언어는 함수를 일급 객체(first class citizen)으로 취급해서 함수를 일반적인 값처럼 취급합니다. 이렇게 때문에 함수를 변수가 대입하거나 함수의 파라미터나 리턴 값으로 전달 할 수 있습니다.

고차 함수

배열 생성자는 람다를 받아서 배열을 생성함과 동시에 초기화를 할 수 있습니다.

val squares = IntArray(5) { n -> n * n } // 0, 1, 4, 9, 16

기존에는 함수 내부 구현을 변경하기가 어려웠다면 고차 함수를 사용하면 실제 동작하는 부분만 외부에서 고차함수로 받아서 처리 할 수 있습니다.

fun sum(numbers: IntArray): Int {
    var result = numbers.firstOrNull()
        ?: throw IllegalArgumentException("Empty array")

    for (i in 1..numbers.lastIndex) {
        result += numbers[i]
    }
    return result
}

fun main() {
    println(sum(intArrayOf(1, 2, 3))) // 6
}

덧셈하는 함수를 곱셈이나 최대값/최소값으로 변경하려면 함수자체를 새로 만들어야합니다.

하지만 고차함수를 통해 어떤 동작을 할 것인지 전달해준다면 함수를 새로 만들지 않고 다르게 동작하도록 처리 할 수 있습니다.

fun aggregate(numbers: IntArray, op: (Int, Int) -> Int): Int {
    var result = numbers.firstOrNull()
        ?: throw IllegalArgumentException("Empty array")

    for (i in 1..numbers.lastIndex) {
        result = op(result, numbers[i])
    }
    return result
}

fun sum(numbers: IntArray) = aggregate(numbers) { result, op -> result + op }
fun max(numbers: IntArray) = aggregate(numbers) { result, op -> if (result > op) result else op }

fun main() {
    println(sum(intArrayOf(1, 2, 3))) // 6
    println(max(intArrayOf(1, 2, 3))) // 3
}

이렇게 op로 전달한 고차 함수만 달라지면 결과가 달라지는 것을 확인할 수 있습니다.

함수 타입

함수처럼 쓰일 수 있는 값들을 표시하는 타입입니다.

함수 타입은 (Type1, Type2...) -> Type 형태로 전달하는 파라미터 타입과 반환되는 타입으로 구성되어 있고 일반 함수와 다르게 -> 를 사용합니다.

fun main() {
    val lessThen: (Int, Int) -> Boolean = { a, b -> a < b }
    println(lessThen(1, 2)) // true

    // shifter는 Int를 파라미터로 받아서 (Int) -> Int 의 고차함수를 반환하는 고차함수 입니다.
    val shifter: (Int) -> (Int) -> Int = { n -> { i -> i + n } } // (Int) -> ((Int) -> Int)
    val inc = shifter(1)
    val dec = shifter(-1)
    println(inc(10)) // 11
    println(dec(10)) // 9
}

람다

위 예제처럼 { a,  b -> a < b } 를 람다식이라고 부릅니다.

함수정의와 달리 반환 타입을 지정할 필요가 없고 추론이 됩니다.

람다에 인자가 없거나 1개면 생략 가능하고 1개인 경우 it 키워드를 통해서 파라미터에 접근이 가능합니다.

fun measureTime(action: () -> Unit): Long {
    val start = System.nanoTime()
    action()
    return System.nanoTime() - start
}

val time = measureTime { 1 + 2 }

fun check(s: String, condition: (Char) -> Boolean): Boolean {
    for (c in s) {
        if (!condition(c)) {
            return false
        }
    }
    return true
}

fun main() {
    println(check("Hello") { c -> c.isLetter() }) // true
    println(check("Hello") { it.isLowerCase() }) // false
}

익명 함수

일반 함수와 문법이 거의 똑같지만 함수이름이 없는 익명 함수를 만들 수 있습니다.

람다와 달리 익명함수를 인자 모록의 밖으로 내보낼 수는 없습니다.

람다를 사용하는게 더 간결하고 사용하기가 쉬워서 잘 사용하지 않는 문법입니다.
fun aggregate(numbers: IntArray, op: (Int, Int) -> Int): Int {
    var result = numbers.firstOrNull()
        ?: throw IllegalArgumentException("Empty array")

    for (i in 1..numbers.lastIndex) {
        result = op(result, numbers[i])
    }
    return result
}

fun sum(numbers: IntArray) = aggregate(numbers, fun(result, op): Int {
    return result + op
})
fun max(numbers: IntArray) = aggregate(numbers, fun(result, op) = if (result > op) result else op)

fun main() {
    println(sum(intArrayOf(1, 2, 3))) // 6
    println(max(intArrayOf(1, 2, 3))) // 3
}

호출 가능 참조

호출 가능 참조를 통해서 함수값을 만들 수 있습니다. 호출 가능 참조는 이미 만들어진 함수를 사용하기 위해서 사용됩니다.

:: 키워드를 사용해서 사용합니다.

fun check(s: String, condition: (Char) -> Boolean): Boolean {
    for (c in s) {
        if (!condition(c)) {
            return false
        }
    }
    return true
}

fun isCapitalLetter(c: Char) = c.isUpperCase() && c.isLetter()

fun main() {
    println(check("Hello", ::isCapitalLetter)) // false
}

코틀린 프로퍼티에 대한 호출 가능 참조를 만들 수도 있습니다. 실제 함수값이 아니라 프로퍼티 정보를 담고 있는 리플렉션(reflection) 객체입니다.

class Person(var firstName: String, var familyName: String)

fun main() {
    val person = Person("석준", "정")
    val readName = person::firstName.getter
    val writeName = person::firstName.setter

    println(readName()) // 석준
    writeName("디노")
    println(readName()) // 디노
}

인라인 함수와 프로퍼티

고차 함수와 함수값을 사용하면 함수가 객체로 표현되기 때문에 성능 차원에서 부가 비용이 발생합니다. 익명 함수나 람다가 외부 영역의 변수를 참조하면 고차 함수에 함수값을 넘길 때마다 이런 외부 영역의 변수를 포획할 수 있는 구조도 만들어서 넘겨야 합니다.

이런 문제들을 해결할 수 있는 해법이 inline을 사용하는 것 입니다.

fun indexOf(numbers: IntArray, condition: (Int) -> Boolean): Int {
    for (i in numbers.indices) {
        if (condition(numbers[i])) {
            return i
        }
    }
    return -1
}

fun main() {
    println(indexOf(intArrayOf(4, 3, 2, 1)) { it < 3 }) // 2
}

// java로 decompile
public final class MainKt {
   public static final int indexOf(@NotNull int[] numbers, @NotNull Function1 condition) {
      Intrinsics.checkNotNullParameter(numbers, "numbers");
      Intrinsics.checkNotNullParameter(condition, "condition");
      int i = 0;

      for(int var3 = numbers.length; i < var3; ++i) {
         if ((Boolean)condition.invoke(numbers[i])) {
            return i;
         }
      }

      return -1;
   }

   public static final void main() {
      int var0 = indexOf(new int[]{4, 3, 2, 1}, (Function1)null.INSTANCE);
      System.out.println(var0);
   }

   // $FF: synthetic method
   public static void main(String[] var0) {
      main();
   }
}
inline fun indexOf(numbers: IntArray, condition: (Int) -> Boolean): Int {
    for (i in numbers.indices) {
        if (condition(numbers[i])) {
            return i
        }
    }
    return -1
}

fun main() {
    println(indexOf(intArrayOf(4, 3, 2, 1)) { it < 3 }) // 2
}

// java로 decompile
public final class MainKt {
   public static final int indexOf(@NotNull int[] numbers, @NotNull Function1 condition) {
      int $i$f$indexOf = 0;
      Intrinsics.checkNotNullParameter(numbers, "numbers");
      Intrinsics.checkNotNullParameter(condition, "condition");
      int i = 0;

      for(int var4 = numbers.length; i < var4; ++i) {
         if ((Boolean)condition.invoke(numbers[i])) {
            return i;
         }
      }

      return -1;
   }

   public static final void main() {
      int[] numbers$iv = new int[]{4, 3, 2, 1};
      int $i$f$indexOf = false;
      int i$iv = 0;
      int var3 = numbers$iv.length;

      int var10000;
      while(true) {
         if (i$iv >= var3) {
            var10000 = -1;
            break;
         }

         int it = numbers$iv[i$iv];
         int var5 = false;
         if (it < 3) {
            var10000 = i$iv;
            break;
         }

         ++i$iv;
      }

      int var6 = var10000;
      System.out.println(var6);
   }

   // $FF: synthetic method
   public static void main(String[] var0) {
      main();
   }
}

이렇게 inline을 사용하면 코드량은 늘어나지만 적절하게 잘 사용하면 성능을 크게 높일 수 있습니다.

inline은 항상 인라인이 되며 불가능한 경우 컴파일 오류가 발생합니다.

 

inline 함수는 널이 될 수 있는 함수 타입의 인자를 받을 수 없습니다. 이런 경우는 noinline 키워드를 사용합니다.

inline fun forEach(a: IntArray, noinline action: ((Int) -> Unit)?) {
    if (action == null) return
    for (n in a) {
        action(n)
    }
}

public inline 함수에 private이나 protected처럼 비공개 멤버를 넘길 수 없습니다.

class Person(
    protected val firstName: String,
    private val familyName: String,
) {
    inline fun sendMessage(message: () -> String) {
        // Protected function call from public-API inline function is prohibited
        // Public-API inline function cannot access non-public-API 'private final val familyName: String defined in Person'
        println("$firstName $familyName ${message()}")
    }
}

프로퍼티가 custom getter나 custom setter를 가진다면 그것도 inline으로 만들 수 있습니다.

class Person(var firstName: String, var familyName: String) {
    // get만 inline
    var fullName: String
        inline get() = "$firstName $familyName"
        set(value) {
            
        }

    // get/set 둘다 inline
    inline var fullName2: String
        get() = "$firstName $familyName"
        set(value) {

        }
}

비지역적 제어 흐름

고차함수를 사용하면 return 문 등과 같이 일반적인 제어 흐름을 깨는 명령을 사용할 때 문제가 생긴다.

레이블을 사용해서 명시적으로 제어 흐름을 깨줄수 있습니다.

fun forEach(a: IntArray, action: (Int) -> Unit) {
    for (n in a) {
        action(n)
    }
}

fun main() {
    forEach(intArrayOf(1, 2, 3, 4)) {
        if (it < 2 || it > 3) return@forEach
        println(it)
    }
}

확장

현업에서 개발할 때 클래스에 함수나 프로퍼티를 추가해서 기존 구현된 클래스의 기능을 추가해서 사용하고 싶은 니즈가 있습니다.

기존 자바에서는 유틸클래스를 만들어서 사용했는데 코틀린에서는 기존 클래스가 제공하는 것처럼 함수나 프로퍼티를 만들 수 있습니다.

확장 함수

함수를 정의할 때 사용할 수신 객체의 클래스 이름을 먼저 표시하고 . 을 추가해서 함수를 정의합니다.

이렇게 하면 기존에 클래스의 함수처럼 사용 할 수 있습니다.

fun String.truncate(maxLength: Int): String {
    return if (length <= maxLength) this else substring(0, maxLength)
}

fun main() {
    println("Hello".truncate(10)) // Hello
    println("Hello".truncate(3)) // Hel
}

확장 함수에서는 클래스의 private이나 protected에 접근할 수 없습니다.

class Person(val name: String, private val age: Int) {
    fun Person.showInfo() = println("$name $age") // age 접근 가능 
}

fun Person.showInfo() = println("$name $age") // Cannot access 'age': it is private in 'Person'

멤버 함수와 확장 함수가 동일하다면 멤버 함수가 호출 됩니다.

이것은 기존 클래스의 동작이 사고로 변경되는 것을 방지해줍니다.

class Person(val name: String, val age: Int) {
    fun showInfo() = println("member function $name $age")
}

fun Person.showInfo() = println("extension function $name $age")

fun main() {
    val person = Person("석준", 31)
    person.showInfo() // member function 석준 31
}

null이 될 수 있는 타입에 대해 확장함수를 만들 수 있습니다.

이런 경우 null safe call이 되는게 아니라 함수를 실행할 수 있습니다.

fun String?.truncate(maxLength: Int): String? {
    if (this == null) return null
    return if (length <= maxLength) this else substring(0, maxLength)
}

fun main() {
    val s = readLine()
    println(s.truncate(3))
}

확장 프로퍼티

확장 함수와 비슷하게 확장 프로퍼티를 만들 수 있습니다.

val IntRange.leftHalf: IntRange
    get() = first..(start + last) / 2

fun main() {
    println((1..3).leftHalf) // 1..2
    println((3..6).leftHalf) // 3..4
}

가변 프로퍼티인 경우에는 setter도 같이 구현해줘야만 합니다.

val IntArray.midIndex
    get() = lastIndex / 2

var IntArray.midValue
    get() = this[midIndex]
    set(value) {
        this[midIndex] = value
    }

fun main() {
    val numbers = IntArray(6) { it * it }

    println(numbers.midValue) // 4
    numbers.midValue *= 10
    println(numbers.midValue) // 40
}

동반 확장

Companion에도 확장을 적용할 수 있습니다.

fun IntRange.Companion.singletonRange(n: Int) = n..n

fun main() {
    println(IntRange.singletonRange(5)) // 5..5
    println(IntRange.Companion.singletonRange((3))) // 3..3
}

하지만 companion이 존재하지 않으면 정의할 수 없습니다.

class Person(val firstName: String, val familyName: String)

val Person.Companion.UNKNOWN by lazy { Person("석준" ,"정") } // Unresolved reference: Companion

람다와 수신 객체 지정 함수 타입

람다나 익명 함수에 확장 수신 객체를 활용할 수 있습니다.

fun aggregate(numbers: IntArray, op: Int.(Int) -> Int): Int {
    var result = numbers.firstOrNull()
        ?: throw IllegalArgumentException("Empty array")

    for (i in 1..numbers.lastIndex) {
        result = result.op(numbers[i])
    }

    return result
}

fun sum(numbers: IntArray) = aggregate(numbers) { op -> this + op }

aggregate의 op 파라미터를 보면 수신객체의 타입으로 Int를 넣어서 정의하고 sum 함수의 구현부에 람다에서 this를 사용할 수 있습니다. 

그리고 아래처럼 익명 함수를 구현할 수 있습니다.

fun sum(numbers: IntArray) = aggregate(numbers, fun Int.(op: Int) = this + op)

수신 객체가 있는 호출 가능 참조

호출 가능 참조에서 확장에 쓰인 수신 객체가 비확장의 첫번째 파라미터의 타입과 같다면 확장과 비확장 둘다 문법적으로는 다르지만 동일하게 사용 할 수 있습니다.

fun aggregate(numbers: IntArray, op: Int.(Int) -> Int): Int {
    var result = numbers.firstOrNull()
        ?: throw IllegalArgumentException("Empty array")

    for (i in 1..numbers.lastIndex) {
        result = result.op(numbers[i])
    }

    return result
}

fun Int.max(other: Int) = if (this > other) this else other
fun min(origin: Int, other: Int) = if (origin < other) origin else other

fun main() {
    val numbers = intArrayOf(1, 2, 3, 4)
    println(aggregate(numbers, Int::max))
    println(aggregate(numbers, ::min))
}

영역 함수

영역 함수(scope function)은 run, let, with, apply, also로 구성되어 있고 전부 inline 함수이기 때문에 런타임 부가비용이 없습니다.

남용하면 코드 가독성이 나빠지고 실수하기도 쉬워져서 조심히 사용해야 하고 영역 함수 안에 영역 함수를 사용할 때도 조심해야 합니다.

 

run 함수는 확장 람다를 받는 확장 함수이며 람다의 결과를 리턴합니다. 또한 확장 함수가 아닌 일반 함수 형태로도 제공 됩니다. 일반 함수도 동일하게 람다의 결과를 리턴합니다.

public inline fun <T, R> T.run(block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}

public inline fun <R> run(block: () -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}

 

with 함수는 첫 번째 파라미터로 값을 전달 하고 람다의 결과를 리턴합니다.

public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return receiver.block()
}

 

let 함수는 확장 함수 타입의 람다를 받지 않고 인자가 하나뿐인 함수의 타입을 받는 확장 함수이고 람다의 결과를 리턴합니다.

public inline fun <T, R> T.let(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block(this)
}

 

apply 함수는 run과 같이 확장 람다를 받는 확장 함수이지만 자신의 수신 객체를 리턴합니다.

public inline fun <T> T.apply(block: T.() -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block()
    return this
}

 

also 함수는 let과 같이 확장 함수 타입의 람다를 받지 않고 인자가 하나뿐인 함수의 타입을 받는 확장 함수이지만 자신의 수신 객체를 리턴합니다.

public inline fun <T> T.also(block: (T) -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block(this)
    return this
}

 

scope function

클래스 멤버인 확장

클래스 안에서 확장 함수나 프로퍼티를 선언하면 일반적인 멤버나 최상위 확장과 달리 이런 함수나 프로퍼티에는 수신 객체가 두 개 있다.

확장 정의의 수신 객체 타입의 인스턴스를 확장 수신 객체(extension receiver)라 부르고, 확장이 포함된 클래스 타입의 인스턴스를 디스패치 수신 객체(dispatch receiver)라 부릅니다.

class Address(val city: String, val street: String, val house: String)

class Person(val firstName: String, val familyName: String) {
    fun Address.post(message: String) {
        // 암시적 this: 확장 수신 객체(Address)
        val city = city
        // 한정시키지 않은 this: 확장 수신 객체(Address)
        val street = this.street
        // 한정시킨 this: 확정 수신 객체(Address)
        val house = this@post.house
        // 암시적 this: 디스패치 수신 객체(Person)
        val firstName = firstName
        // 한정시킨 this: 디스패치 수신 객체(Person)
        val familyName = this@Person.familyName

        println("From $firstName, $familyName at $city, $street, $house:")
        println(message)
    }

    fun test(address: Address) {
        // 디스패치 수신 객체: 암시적
        // 확장 수신 객체: 명시적
        address.post("Hello")
    }
}

 

class Address(val city: String, val street: String, val house: String) {
    fun test(person: Person) {
//        person.post("Hello") // Unresolved reference: post 
        
        // Person 타입의 디스패치 수신 객체가 현재 영역에 존재해야만 post함수를 호출 할 수 있으므로
        // Person을 수신객체로 감싸야 Person안에 있는 Address.post()함수를 실행 할 수 있음
        with(person) { 
            post("Hello")
        }
    }
}

class Person(val firstName: String, val familyName: String) {
    fun Address.post(message: String) {
        
    }
}
하지만 이런 예는 혼란을 야기할 수 있기 때문에 사용하지 않는 것이 좋거나 class 내부에서만 사용할 수 있도록 protected나 private로 한정하는게 좋습니다.
728x90
반응형

'개발 > 코틀린' 카테고리의 다른 글

(코틀린) 컬렉션  (0) 2022.10.30
(코틀린) 특별한 클래스 사용하기  (0) 2022.10.23
(코틀린) 클래스  (0) 2022.10.03
(코틀린) 예외처리  (0) 2022.09.25
(코틀린) 조건문, 반복문  (0) 2022.09.25