[kotlin] let, run, apply, also 사용하기

코틀린에는 모든 형에 적용되는 기본 인라인 확장함수가 제공됩니다. 그 중 let, run, apply, also에 대해 간단히 정리해보는 시간을 갖겠습니다.

let과 run 혹은 apply와 also

let과 run은 서로 비슷한 일을 하지만 let의 경우는 인자로 대상이 들어오는데 비해 run은 this로 대상이 들어옵니다. 결과값은 람다의 반환값이 되죠. 즉 람다의 반환값이 해당 확장함수의 결과값이 된다는 점은 동일한데 수신객체를 인자로 받을 것이나 this로 받을 것이냐의 차이점입니다. also와 apply도 마찬가지 관계지만 블록의 반환값과 상관없이 수신객체가 반환값이 됩니다.

그럼 언제 인자로 받는게 유리한지 this로 받는게 유리한지를 판단해봐야겠죠.

  1. 블록 내에서 수신객체의 속성이나 메소드를 많이 사용한다면 run이나 apply를 사용해 this.을 생략할 수 있어 많은 반복코드를 줄일 수 있습니다. 그에 비해 let이나 also는 it.을 다 붙여줘야하죠.
  2. 하지만 1번 경우에도 수신객체가 Map계열이라면 this[key] = value 형태로 블록 내 코드가 작성될거라 이럴거면 차라리 it[key] = value 쪽이 코드가 짧고 읽기도 편합니다. 따라서 run은 DSL형태에 적합하고 apply를 객체 초기화나 빌더패턴에 적합하다고 볼 수 있죠.

run과 apply의 중첩된 컨텍스트 해석문제

run과 apply의 경우 this의 중첩에 대해서도 생각해볼 문제입니다. 블록 내에서 외부의 this와 수신객체의 this를 동시에 사용하고 싶다면 run을 사용할 수는 없을 것입니다. run은 결국 중첩된 this.의 생략을 유도하여 해당 변수가 어디 소속인지 혼란하게 만드는 효과가 있습니다. 인자의 경우 이름이 외부와 중복되면 쉐도우 경고를 해줍니다. 하지만 this의 경우는 그런게 없기 때문에 개발자의 주의력을 요구하게 됩니다.

class Wrapper(var value:Int){
  class Action(var value:Int)
  fun action(value:Int){
    return Action(5).run{
      value * 3
    }
  }
}
Wrapper(5).action(2) //??

위 코드에서 실제 run에 등장하는 변수들은 굉장한 주의력을 요구합니다. run 블록 내에 value는 세 가지 의미 중에 하나게 됩니다.

  1. Wrapper클래스의 value속성
  2. Action클래스의 value속성
  3. action함수의 value인자

코틀린 스코프 규칙에 의해 value는 3번으로 해석됩니다. 하지만 run을 쓰는 순간 개발자는 2번을 기대하기 마련이죠. 알면서도 코베이는 순간입니다. 그래서 결과로 기대하는게

  • Action(5)의 5와 3을 곱한 15로 기대하게 되지만
  • 실제로는 action에 인자로 들어온 2 * 3이 되어 6이 나오게 됩니다.

만약 let을 사용한다면 최소한 블록의 수신객체에 대한 혼란은 막을 수 있습니다.

class Wrapper(var value:Int){
  class Action(var value:Int)
  fun action(value:Int){
    return Action(5).let{
      it.value * 3
    }
  }
}
Wrapper(5).action(2)

중첩된 컨텍스트는 this.의 생략에 의해 다양한 착시와 혼란을 일으키게 됩니다. 그런 점을 고려한다면 의도하지 않은 이상 let과 also가 미연에 실수를 방지합니다. 하지만 DSL의 경우는 처음부터 그 점을 노려서 this메소드의 쉐도우를 의도하기 때문에 apply와 run이 더 나은 선택이 될 수 있습니다.

abstract class Element{
  var title = ""
  val children by lazy{mutableSetOf<Element>()}
  fun append(child:Element){
    children += child
  }
}
class A:Element(){
  var href = ""
}
class Img:Element(){
  var src = ""
}

val link = A().apply{
  title = "main"
  href = "linkURL"
  append(Img().apply{
    title = "mainImage"
    src = "main.jpg"
  })
}

위 코드에서 Element에 title이 정의되어 A와 Img는 각각 title을 소유하게 됩니다. 중첩된 스코프에서 Img의 title은 A의 title과 변수명이 중복되지만 이 경우 this가 중첩되는 걸 오히려 자연스럽게 인식하여 Img용 title을 지정하고 있다고 생각하게 됩니다.

하지만 이러한 코드에서도 지역변수는 반드시 조심해야 합니다.

val link = A().apply{
  var title = "main" <--------
  href = "linkURL"
  append(Img().apply{
    title = "mainImage"
    src = "main.jpg"
  })
}

고작 저 title을 지역변수로 만드는 것만으로 Img().apply블록 내의 title은 Img의 속성이 아닌 지역변수를 갱신하는 코드로 변해버립니다. 귀찮지만 also를 쓰면 이 문제를 확실하게 방지할 수 있습니다.

val link = A().also{
  var title = "main"
  it.title = "main"
  it.href = "linkURL"
  it.append(Img().also{
    it.title = "mainImage"
    it.src = "main.jpg"
  })
}

더 이상 어딘지 모를 중첩스코프의 자유변수에 전혀 영향을 받지 않는 방탄(?) 속성처리가 되었습니다.

결론

사실 제 개인적인 권장은 항상 let, also를 쓰라는 것입니다. 인텔리제이는 중첩된 변수명에 대해 쉐도우경고를 합니다. 하지만 중첩된 this는 그런 안전장치가 없고 더 나아가 자유변수와의 충돌도 경고하지 않습니다. 처음에는 주의깊게 작성했다해도 유지보수를 반복하면서 자유변수가 나중에 등장할 가능성이 높고 그 이름이 여러 DSL객체의 속성과 일치하지 않는다는 확실한 보장도 없습니다.

하지만 run, apply가 이런 문제가 없을 땐 코드가 상대적으로 짧아서 거부하기 힘들죠 ^^; 문제가 일어나는 상황만 잘 인식하고 통제한다면 편의성에 맞춰 써도 상관없다는 생각도 합니다.