[kotlin] 람다를 확장함수로 대체하기

개요

람다는 다양한 곳에서 활용됩니다. 헌데 람다를 만드는 비용은 Function인터페이스를 따르는 제네릭 객체이므로 반복적인 일에 매번 지불하게 되어 성능이 중요한 곳에서는 부담이 되곤 합니다. 일반적으로 성능이 중요한 곳은 프론트의 애니메이션 처리처럼 매 프레임마다 동작하는 객체이거나 방대한 루프를 사용하는 로직입니다. 이 경우 경량 객체가 중요하고 어떻게 하면 내부 객체를 줄일 수 있게 고민하게 됩니다.

더불어 람다를 사용하는 경우 현재 컨텍스트의 변수나 속성이 전부 클로저로 잡힌다는 잇점도 무시할 수 없습니다. 어떤 로직을 진행하는데 필요한 값이 스코프를 통해 인식되고 그걸 바탕으로 재활용되는 알고리즘을 사용한다면 매번 컨텍스트에 따라 람다를 만들 수 밖에 없는거죠.

이번 글에서 집중하는 것은 바로 속성(필드)에 의존하는 람다를 확장함수를 통해 companion object 수준의 객체로 올려 하나만 만들어 인스턴스들이 공유하는 방법에 대한 것입니다.

흐름제어를 위해 람다를 넘겨주는 경우

예를 들어 다음과 같은 리스트를 생각해보겠습니다.

interface Task

val list = setOf<Task>()

이제 이 list를 바탕으로 any를 돌려 중간에 멈출 수 있는 옵션을 줄건데 멈출 것이냐 말것이냐는 각 Task가 결정할 수 있게 한다고 생각해보죠.

interface Task{
  operator fun invoke():Boolean
}

val list = setOf<Task>()
list.any{
  it()
}

그럼 위와 같이 구현되고 각 Task는 invoke를 구현해서 true/false를 반환해야 합니다. 이번엔 Task를 실제로 구현해보죠.

interface Task{
  operator fun invoke():Boolean
}

val list = setOf<Task>(
  object:Task{
    override fun invoke():Boolean{
      //run something
      return true //or false
    }
  }
)

list.any{
  it()
}

실제 구현된 Task의 invoke는 반드시 true/false를 반환하게 되는 구조를 갖게 됩니다. 뭐가 불만이냐구요? 없습니다. 근데 만약 대부분 true상황이고 특별한 경우에만 false를 반환한다고 생각하면 좀 귀찮습니다.

멈추고 싶을 때만 멈추면 되는데 항상 return을 해야하니 귀찮다는 거죠.

특히 코틀린 1.4부터는 SAM이 제공되므로 다음과 같이 작성해도 무방합니다.

fun interface Task{
  operator fun invoke():Boolean
}

val list = setOf<Task>(
  Task{
    //run something
    true
  },
  Task{
    if(something) false else true
  }
)

list.any{
  it()
}

이렇게 보면 더욱 멈추고 싶을 때만 처리하고 싶은데, 모든 Task가 강제적으로 Boolean을 반환해야 합니다.

이걸 해결하려면 인자로 전달한 람다를 호출하면 정지시키는 구조로 바꾸면 됩니다. 하지만 내부에 필드도 필요하고 람다도 필요하니 class로 바꿉니다.

class Task(private val block:(()->Unit)->Unit){ //---1
  private var isStop = false
  private val stop:()->Unit = {isStop = true}
  operator fun invoke():Boolean{ //---2
    block(stop)
    return isStop
  }
}

val list = setOf<Task>(
  Task{
    //run something ---3
  },
  Task{
    if(something) it() //---4
  }
)

list.any{
  it()
}
  1. 이젠 더 이상 인터페이스로부터 매번 새로운 객체를 만들지 않고 구상 클래스 Task가 람다를 받는 것(전략객체)으로 다형성을 대신합니다.
  2. invoke는 우선 block을 실행하고 block내부에서 stop이 불린 적이 있다면 isStop이 true로 바뀔 것이고 아니라면 원래 false가 반환될 것입니다.
  3. 만약 어떤 Task딱히 정지할 필요가 없다면 그냥 코드를 작성하면 됩니다.
  4. 하지만 정지하고 싶다면 인자로 받은 stop람다를 it()으로 호출하여 정지시킬 수 있죠.

stop람다 제거하기

Task클래스에서 block은 제거할 수 없습니다. 본질적인 전략 객체이기 때문이죠. 허나 stop람다는 그저 흐름제어를 위한 람다일 뿐더러 정지할 필요가 없을 때는 사용도 안하는데 무조건 만들어야 합니다.

이를 companion으로 옮길 수 있다면 좋겠죠.

헌데 stop함수가 내부에서 isStop이라는 인스턴스의 속성을 사용합니다. 그렇다는건 인스턴스 자체도 stop함수가 인식할 수 있어야만 companion으로 갈 수 있다는 것이죠. 이를 응용하면 다음과 같이 고칠 수 있습니다.

class Task(private val block:((Task)->Unit)->Unit){ //---1
  companion object{
    private val stop:(Task)->Unit = {it.isStop = true} //---2
  }
  private var isStop = false
  operator fun invoke():Boolean{ 
    block(stop)
    return isStop
  }
}

val list = setOf<Task>(
  Task{
    //run something
  },
  Task{
    if(something) it(this) //---3
  }
)

list.any{
  it()
}
  1. 이제 block은 Task를 인자로 받는 람다를 다시 인자로 하는 람다입니다.
  2. stop을 companion으로 옮겨 인스턴스마다 만들지 않게 되었습니다. 대신 인자로 Task를 받아야만 그 Task의 isStop을 변경할 수 있죠.
  3. 따라서 정지시키고 싶을 때도 this를 인자로 보내줘야 합니다. 하지만 이때 this는 해당 Task의 인스턴스가 아닙니다. 따라서 현재 Task를 인식할 수 있는 방법이 따로 필요합니다.

3번에 따라 애당초 현재 Task를 인자로 보내야만 한다는 사실을 알 수 있습니다. 그에 맞춰 block도 Task를 받아들이고 any루프 안에서도 해당 Task를 넘겨주는 방식으로 코드를 변경합니다.

class Task(private val block:(Task, (Task)->Unit)->Unit){ //---1
  companion object{
    private val stop:(Task)->Unit = {it.isStop = true} 
  }
  private var isStop = false
  operator fun invoke():Boolean{ 
    block(this, stop) //---2
    return isStop
  }
}

val list = setOf<Task>(
  Task{_, _->
    //run something
  },
  Task{task, stop-> //---3
    if(something) stop(task) 
  }
)

list.any{
  it()
}
  1. 우선 stop에 Task를 보내기 위해서는 이를 사용하는 block이 현재 Task를 알아야함으로 추가적인 인자를 받게 변경합니다.
  2. 실제 block호출 시 this를 보내줍니다.
  3. 실제 Task는 인자로 받은 task를 stop에 넘겨주는 형태로 사용합니다.

아니 이래서야 더욱 불편해졌을 뿐입니다. 이걸 개선할 방법이 있을까요.

첫 번째 인자가 객체면 확장함수다

의외겠지만 확장함수란 결국 첫 번째 인자로 특정 객체를 받아들이는 함수입니다. 이를 확장함수로 선언하면 컴파일러가 첫 번째 인자를 this로 대체해주는 것이죠. 따라서 다음의 두 함수는 실질적으로 컴파일된 결과가 같습니다.

fun action(task:Task, value:Any)
action(someTask, 3)

fun Task.action(value:Any)
someTask.action(3)

위와 아래의 코드를 보면 더욱 확연하게 이해할 수 있습니다. 말하자면 확장함수란 첫 번째 인자로 넘겨야할 객체를 앞에 두고 점으로 함수를 연결할 수 있게 바꿔주는 매크로 기능입니다. 헌데 this의 경우 생략할 수 있으므로 다음과 같이 사용할 수도 있습니다.

fun Task.action(value:Any)

someTask.apply{
  action(3) //---1
}

action(someTask, 3) //---2
  1. 우선 apply하에서는 컨텍스트가 someTask로 주어지므로 굳이 this.을 붙이지 않고 생략해도 됩니다. 그러면 자연스럽게 action(3)만 호출해도 this.action(3)과 같은 효과가 됩니다.
  2. 직접 action함수를 호출해도 상관없습니다. 이 경우는 첫 번째 인자로 대상 객체를 보내줘야 합니다. 원래 이렇게 컴파일되기 때문입니다.

이제 Task클래스를 개선해볼 수 있습니다.

class Task(private val block:Task.(Task.()->Unit)->Unit){ //---1
  companion object{
    private val stop:Task.()->Unit = {isStop = true} //---2
  }
  private var isStop = false
  operator fun invoke():Boolean{ 
    block(stop) //---3
    return isStop
  }
}

val list = setOf<Task>(
  Task{
    //run something
  },
  Task{
    if(something) it() //---4
  }
)

list.any{
  it()
}

이제 복잡한 컴파일러의 매직이 일어납니다. 어렵기 때문에 차근차근 살펴보죠.

  1. 생성자가 인자로 받는 block은 복잡해보입니다. Task.()->Unit을 인자로 받는 Task의 확장함수죠. 이 블록에 현재 인스턴스를 전해주기 위해 전체적으로 Task의 확장함수가 되었고 인자로 들어오는 stop람다에게도 그 인스턴스를 전달하게 됩니다.
  2. 이제 stop람다는 this.isStop = true 상태이므로 앞에 this.을 생략하여 만들 수 있습니다.
  3. invoke내의 block(stop)은 사실 this.block(stop)으로 현재 인스턴스를 this컨텍스트로 전달합니다.
  4. 최종적으로 it()을 호출하는데 이는 this.it()의 단축표현으로 이 때 주어진 this역시 해당 인스턴스를 의미하게 됩니다.

확장함수를 통해 4번에서 훨씬 간결한 표현이 가능해집니다.

결론

확장함수는 결국 첫 번째 인자를 수신객체로 표현하는 일종의 문법적인 매크로 장치입니다. 하지만 this.을 생략할 수 있다는 장점으로 실제 최종 사용측 코드는 간결해집니다. 또한 사용법에 따라 private같은 캡슐화를 전혀 깨지 않고도 효율성을 추구할 수 있게 됩니다.

오히려 첫번째 인자가 대상 객체가 오는 형태의 함수로 유도하고 확장함수를 적용하면 this.의 생략으로 상당히 코드가 깔끔해지는 것을 알 수 있죠.

(Task, (Task)->Unit)->Unit 과 같은 람다는 인자를 직접 넘겨야하는 형태로 호출측이 작성되지만 Task.(Task.()->Unit)->Unit으로 바꿔쓰면 this.의 생략으로 첫번째 인자를 자동으로 전달하게 되고 인자의 갯수도 2개 이상에서 1개나 0이 되어 it으로 정리할 수 있게 됩니다.

%d 블로거가 이것을 좋아합니다: