컬렉션 함수를 사용하면 간결하고, 효율적이고, 이해하기 쉬운 코드를 만들 수 있다.

또한, 컬렉션 함수는 아래처럼 연쇄 호출(method chaining)을 할 수 있다.

people.filter { it.name.startsWith("A") }.map { it.name }

하지만 단점이 없는 건 아니다. 연쇄 호출을 하면 매번 중간(임시) 컬렉션을 생성한다.
위 예시에서 filter, map의 결과로 컬렉션 객체가 2개 생성된다. 이것은 원소의 수가 수십만 개 이상일 때 효율을 떨어뜨린다.

이러한 상황을 효율적으로 바꾸고 싶으면 컬렉션을 직접 사용하는 대신 시퀀스(Sequence)를 사용해야 한다.

시퀀스 (Sequence)

people.asSequence()     // 원본 컬렉션을 시퀀스로 변환
    .map(Person::name)    // 시퀀스도 컬렉션과 같은 API를 제공한다.
    .filter { it.startsWith("A") }
    .toList() // 결과 시퀀스를 리스트로 되돌린다

위 코드는 이전 예시의 시퀀스 사용 버전이다.

시퀀스를 사용하면 중간 컬렉션이 생성되지 않아서 원소가 많을 때 성능이 매우 좋아진다.

시퀀스란?

시퀀스(Sequence)는 기본적으로 인터페이스이다.** 시퀀스 자체는 자신이 갖고 있는 데이터를 순서대로 반환할 수 있는 데이터 타입**임을 의미한다.

public interface Sequence<out T> {
    /**
     * Returns an [Iterator] that returns the values from the sequence.
     */
    public operator fun iterator(): Iterator<T>
}

인터페이스에는 iterator 메서드 하나만 정의되어 있는데, 반환되는 Iterator를 통해 원소를 하나씩 리턴받을 수 있다.

시퀀스가 중간 컬렉션을 생성하지 않는 이유는 이 인터페이스를 구현하고 있는 클래스의 연산 수행 방식에 있다.

시퀀스의 연쇄 연산 순서

시퀀스는 원소 단위로 연산을 수행한다.

listOf(1,2,3,4).asSequence() // 컬렉션을 시퀀스로 변경
        .map { print("map($it) "); it * it }
        .filter { print("filter($it) "); it % 2 == 0 }
        .toList() // 결과 시퀀스를 리스트로 되돌린다

컬렉션으로 위 연산을 진행한다면 모든 원소에 대해 map 함수를 수행하고, 그 결과 컬렉션에 filter 함수를 수행한다. 따라서, map에 의한 중간 컬렉션이 생성된다.


반면 시퀀스는 첫 번째 원소를 map 연산하고, 그 결과 값에 filter를 수행한다. 그리고 두 번째 원소에 대해 map -> filter 연산을 수행한다.

위 코드의 결과는 아래와 같다.

map(1) filter(1) map(2) filter(4) map(3) filter(9) map(4) filter(16)

이렇게 원소 단위로 연산을 연쇄 수행하기 때문에 중간 컬렉션이 생기지 않는다.

꼭 시퀀스를 컬렉션으로 되돌려야 하나

예시를 보면 마지막에 시퀀스를 리스트로 되돌린다. 시퀀스가 효율적이면 시퀀스를 계속 쓰는 게 좋을텐데 말이다.

시퀀스의 결과를 차례로 이터레이션 하기만 할거면 시퀀스를 써도 된다. 하지만 인덱스 접근과 같이 컬렉션에서 제공하는 기능을 사용해야 한다면 컬렉션으로 되돌려야 한다.

지연(lazy) 연산

시퀀스의 또 하나의 특징은 지연 연산을 한다는 것이다.

val sequence = List(1, 2, 3, 4).asSequence()
        .map { print("map($it) "); it * it }
        .filter { print("filter($it) "); it % 2 == 0 }

위 코드는 이전 예시에서 마지막 toList() 호출부를 제외한 것이다. 이 코드를 수행하면 아무런 내용도 출력하지 않는다. 시퀀스는 최종 시퀀스를 이터레이션하거나 리스트로 변환해야 비로소 연산이 수행되기 때문이다.

위 코드는 그저 어떤 연산을 수행해서 원소를 반환하는 시퀀스인지를 알려줄 뿐이다.

정리

원소의 개수가 많은 거대한 컬렉션에 연쇄적인 연산을 적용해야 할 경우 시퀀스를 사용하자.

시퀀스의 특징

  • 중간 컬렉션을 사용하지 않아서 연속적인 계산을 효율적으로 할 수 있다.
  • 지연 연산을 한다.

참조

책: Kotlin In Action

람다는 함수에 인자로 전달하거나 변수에 저장할 수 있는 작은 코드 블록을 말한다. 간략한 형태의 함수라고 볼 수 있다. 매개 변수부와 코드 본문으로 구성되어 있으며, '->'로 둘이 구분된다

val sumLambda = { p1: Type1, p2: Type2 -> p1 + p2 }

 

개발을 하다 보면 이벤트에 대한 핸들러를 등록하거나, 리스트의 모든 원소에 동일한 연산을 적용하기 위해 동작을 함수에 전달하거나 변수에 저장하는 과정이 요구되는데, 이럴 때 람다를 사용한다. 

 

그렇다면 람다는 어떤 점이 특별한 걸까?

 

자바에서는 함수에 코드 블록을 전달하기 위해 익명 객체를 사용한다.

button.setOnClickListener(new OnClickListener() {
	@Override
	public void onClick(View view) {
    	/* 버튼 클릭시 수행할 동작 */
	}
});

하지만 onClick에 정의된 동작을 전달하기 위해 익명 객체를 선언하고 함수를 오버라이딩 해야 하는 등 과정이 번거롭다는 것이 단점이다.

 

코틀린은 이런 번거로움을 함수형 언어처럼 함수를 값으로 다룸으로써 개선했다. 함수 자체를 값처럼 인자로 전달하거나 변수에 저장하는 것이 가능해진 것이다. 여기에 람다를 사용하면, 함수를 정의하지 않고 코드 블록만 넘기는 것이 가능하여 코드가 더욱 간결해진다.

button.setOnClickListener { /* 버튼 클릭 시 수행할 동작 */ }

첫 번째 코드와 같은 동작을 수행하지만 코드가 훨씬 짧아졌다. 참고로, 오버라이딩 해야하는 메서드가 하나인 익명 객체는 모두 람다로 대체할 수 있다.

 

 

그리고 람다는 코틀린의 Collections 라이브러리에서 많이 활용되고 있다. 람다 덕분에 라이브러리에서 컬렉션에 자주 사용되는 기능을 쉽게 제공하는 것이 가능해졌다. 모든 원소에 동일하게 적용할 동작을 람다로 손쉽게 전달할 수 있기 때문이다.

 

예를 들어, 리스트에서 최대 값을 찾고 싶을 때, 자바에서 람다가 나오기 전에는 직접 max 변수를 선언하고 for문을 돌며 찾아야 했다. 하지만 지금은 함수 타입을 인자로 받는 컬렉션 라이브러리 함수를 호출하여 간단하게 해결할 수 있다.

println(people.maxBy { it.age }) // 가장 age가 큰 Person 객체를 출력한다
// 무엇을 기준으로 최대 값을 찾을지를 리턴하는 람다를 전달한다.

코틀린 컬렉션 라이브러리에서는 이런 편한 기능을 정말 많이 제공하고 있다. 덕분에 시간을 아끼면서 간결하고 가독성 좋은 코딩을 할 수 있다.

 

결론: 람다를 사용하면 코드 블록을 함수의 인자로 전달하거나 변수에 저장하는 일을 쉽고 간결하게 할 수 있다.

 

참조

도서 - Kotlin In Action (5장)

scope 함수는 특정 객체에 대한 일시적인 스코프(코드 블록)를 생성해서 그 객체와 관련된 작업을 수행하는 함수이다.

목적은 코드를 더 간결하게 만드는 것에 있다.

사용 예시

아래 코드는 스코프 함수인 let을 사용하고 있다.

Person("Alice", 20, "Amsterdam").let {
    println(it)
    it.moveTo("London")
    it.incrementAge()
    println(it)
}

살펴보면, 객체의 확장 함수 형태로 함수 let을 호출하고 있으며, let은 람다를 인자로 받는다.

람다는 수신 객체(Person)를 인자로 받아서 작업을 수행하는 블록이며, it으로 수신 객체에 접근하고 있다.

 

참고로 스코프 함수를 호출할 때 사용하는 객체를 수신 객체, 수신자, context object 등으로 표현하는데, 나는 수신 객체가 편해서 그렇게 부르고 있다.

let을 사용하지 않은 코드

val alice = Person("Alice", 20, "Amsterdam")
println(alice)
alice.moveTo("London")
alice.incrementAge()
println(alice)

위 코드는 let을 사용하지 않게 바꾼 것이다. 솔직히 이 예시만 봐선 간결해졌는지 잘 모르겠다. 하지만, alice 객체를 사용하는 작업들을 하나로 묶었다는 점은 확실히 눈에 들어온다!


Scope 함수의 종류

스코프 함수에는 let, run, also, apply, with 5개가 있는데, 함수마다 호출 형식에 약간의 차이가 있다. 그 차이를 아래에 표로 첨부했다. 눈에 잘 안 들어온다면 우선 스킵하고 그 아래의 함수 소개로 고고.

함수 객체 참조 리턴 값  확장 함수인가?
let it 람다 리턴 값
Yes 
run this 람다 리턴 값 Yes
run 없음 람다 리턴 값 NO (객체 없이 호출)
with this 람다 리턴 값 NO (인자로 객체 전달)
apply this 수신 객체 Yes
also it 수신 객체 Yes

함수의 호출 형태에서 구분되는 요소는 3가지이다

  • 객체 참조: 수신 객체의 참조를 this로 하는가, it으로 하는가
  • 리턴 값: 리턴 값이 람다의 반환 값인가, 수신 객체 자체인가?
  • 호출 형태: 확장 함수로 호출하는가, 일반 함수처럼 호출 하는가?

1. let

fun <T, R> T.let(block: (T) -> R): R

let은 객체(T)의 확장 함수 형태로 사용된다. 함수의 인자로 람다 블록을 입력 받는데, 람다는 자기 자신(T)을 받아서 R을 반환하며, 람다 안에서 T는 it으로 접근 가능하다. let은 최종적으로 람다의 반환 값 R을 리턴한다.

chain operation에서 사용

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let { 
    println(it)
}

// vs
val resultList = numbers.map { it.length }.filter { it > 3 }
println(resultList)

위와 같이 chain operation의 끝에서 값을 받아서 최종 결과를 만들어 내는 형태로 사용할 수 있다.

그 아랫 줄의 코드는 같은 상황에서 let을 사용하지 않는 경우이다. 한 줄이라서 어떻게 해도 가독성에 차이는 없어 보이지만, let에 들어갈 코드가 많아진다면 let 사용문이 좀 더 간결하게 느껴질 것 같다.

객체가 non-null 일 경우의 작업에 사용

let을 가장 많이 활용하는 형태이다. nullable 객체에 대해 non-null일 경우에 수행할 작업을 정의할 수 있다.

또한, 블록 안에서 non-null로 casting되어서 !!이나 ? 없이 접근할 수 있다.

val str: String? = "Hello"
val str2: String? = null
val length = str?.let { it.length }
val length2 = str2?.let { it.length }
val length3 = str2?.let { it.length } ?: 0
print(length) // 5
print(length2) // null
print(length3) // 0
  • str가 null이 아니므로, length에는 람다의 결과 값인 str의 length가 할당된다.
  • str2는 null이기 때문에 let 함수가 실행되지 않고 null을 반환한다. elvis(?:) 연산자로 null일 때의 처리를 해줄 수도 있다.

2. with

fun <T, R> with(receiver: T, block: T.() -> R): R

with는 let과 달리 일반 함수 형태로 사용된다. 객체 T를 인자로 받아서 임의의 결과 값 R을 반환하는 람다를 입력 받으며, 함수의 최종 결과로 람다의 반환 값 R을 반환한다.

람다에서 받는 객체 T를 보면 T.() 라고 되어 있는데, 이렇게 받으면 확장 함수와 같이 처럼 this로 T에 접근할 수 있다. 또한, this 없이도 T의 멤버에 접근할 수도 있다.

단순히 함수를 호출하는 경우 or 함수 호출을 그룹화 하는 경우

val numbers = mutableListOf("one", "two", "three")
with(numbers) {
    println("인자 $this로 with를 호출했다.")
    println("이것은 $size개의 원소를 갖고 있다.")
}

with(viewModel) {
	dataA.observe(viewLifecycleOwner) {
			// ... 
	}
	this.dataB.observe(viewLifecycleOwner) {
			// ...
		}
}

공식 문서에 따르면 객체의 메서드의 결과 값이 필요하지 않고 호출만 하는 경우에 사용하길 추천하고 있다.

특히, 객체의 여러 함수를 호출할 때 보기 좋게 모아두는 용도로 사용할 수 있다.

값을 계산하기 위한 helper 객체로 활용하는 경우

val firstAndLast = with(numbers) { 
	"FirstElement is ${first()}, the last is ${last()}"
} 
println(firstAndLast)

어떤 객체를 활용해서 값을 계산하고, 결과 값이 필요할 때 사용할 수 있다.


3-1. run (extension)

fun <T, R> T.run(block: T.() -> R): R

run은 확장 함수 형태로 호출된다. 람다 블록은 자신을 T.()로 입력 받기 때문에 this로 접근 가능하고, 임의의 결과 R을 반환한다.

확장 함수로 호출한다는 점을 제외하면 with와 완전히 같지만, 확장 함수 방식 덕분에 null-safe(?.)호출이 가능하다. (object?.run())

객체를 초기화하고 값을 계산하는 경우

val service = MultiportService("<https://example.kotlinlang.org>", 80)
val result = service.run {
    port = 8080
    query(prepareRequest() + " to port $port")
}

// with을 활용한 같은 코드
val letResult = with(service) {
    port = 8080
    query(it.prepareRequest() + " to port ${it.port}")
}

run은 this로 객체 접근이 가능하고 결과 값을 반환하므로, 객체를 초기화 하고 그것으로 값을 계산하는 상황에서 사용하는 것을 공식 문서에서 권장하고 있다.

그 아래는 with로 같은 상황의 코드를 작성한 것이다. 큰 차이가 없어서 앞으로 뭘 사용해야 가독성이 나아질지 고민될 것 같다. with는 읽었을 때 “T객체로 ~~작업을 합니다” 라는 의미가 있어서 개인적으로 객체를 초기화 하는 내용이 있된다면 run이 조금 더 적절할 것 같다.

3-2. run (not extension)

fun <R> run(block: () -> R): R

run은 수신 객체 없이 함수만 호출하는 한 가지의 형태가 더 있다. 입력 받는 람다는 매개 변수 없이 반환 값 R만 존재한다. 함수의 최종 결과는 람다의 R을 반환한다.

여러 중간 값과 함수 호출을 거쳐서 최종 결과를 계산하는 경우

val hexNumberRegex = run {
    val digits = "0-9"
    val hexDigits = "A-Fa-f"
    val sign = "+-"
    Regex("[$sign]?[$digits$hexDigits]+")
}

위 코드는 Regex 객체를 초기화 하고 있다. 그 과정에서 몇 개의 중간 값들을 통해 최종 객체를 생성하는데, 이런 과정을 run 블록으로 묶어 가독성을 높일 수 있다.


4. apply

fun <T> T.apply(block: T.() -> Unit): T

apply는 확장 함수로 호출되며, 람다에서 수신 객체를 인자로 입력 받는다. 하지만 let, with, run과 다르게 람다에 반환 값이 없고 함수의 최종 결과로 수신 객체 T를 다시 반환한다. 람다의 매개변수 타입이 T.()이므로 this로 수신 객체에 접근한다. ****

객체를 초기화(설정 변경) 하는 경우

val adam = Person("Adam").apply {
    age = 32
    city = "London"        
}

this로 멤버에 쉽게 접근 가능하고 자기 자신을 리턴하기 때문에 객체의 초기화나 설정을 변경할 때 유용하다.


5. also

fun <T> T.also(block: (T) -> Unit): T

apply처럼 확장 함수로 호출되고 수신 객체를 다시 리턴한다. apply와 달리 람다에서 (T)로 수신 객체를 받아서 it으로 접근한다.

객체를 부가적으로 활용해서 다른 작업을 할 때 (ex 함수 인자로 사용)


val numbers = arrayListOf("one", "two", "three")
numbers
    .also { println("add 하기 전에 print: $it") }
    .add("four")

수신 객체를 위한 작업보다 수신 객체를 이용한 다른 작업을 할 때 사용하는 것이 권장된다. 함수의 이름처럼 ***“아, 그리고 ~~한 작업도 함께 해라”***라고 해석할 수 있다. ******


함수별 용도

이 함수는 반드시 이런 상황에서만 써야 해! 이런 건 없지만, 공식 문서에서 권장하는 함수별 이용 상황을 다시 정리해보면 다음과 같다.

  • let
    • non-null 객체에 대한 작업을 수행
    • chain operation의 마지막에 사용하여 가독성 향상
  • with
    • 객체의 함수 호출을 그룹핑 and 결과 값이 필요하지 않을 때
  • run
    • 객체의 설정을 변경하고 그것을 활용한 연산 결과를 반환
    • 하나의 작업이지만 여러 중간 값과 함수 호출이 필요할 때 한 블록으로 모아서 가독성 향상
  • apply
    • 객체의 설정 변경 (ex: 프로퍼티 초기화)
  • also
    • 객체를 타겟팅한 작업이 아니라 객체를 활용한 다른 작업을 수행 (ex 함수 인자로 사용)

참조

의존성 주입(Dependency Injection, DI)이란

의존성 주입이란, 의존성을 클래스 밖에서 생성해서 내부로 전달하는 프로그래밍 방식이다.

의존성은 클래스 A가 클래스 B의 객체를 참조할 때, A가 B에 의존한다 또는 A가 B에 대한 의존성을 갖는다고 한다.

따라서, 의존성 주입은 어떤 클래스의 내부에서 사용되는 객체를 클래스 외부에서 생성해서 전달하는 것이다.


객체를 내부에서 생성할 때 발생하는 문제

왜 굳이 객체를 클래스 안에서 생성하지 않고 밖에서 생성하려는 걸까? 객체를 내부에서 생성하는 예시는 다음과 같다.

class Bread() {
    private val milk: Milk = SeoulMilk()
}

이러한 방식의 문제는 특정한 클래스와 강하게 결합한다는 점이다. 어떤 타입의 구현 클래스가 당장 하나 뿐이면 괜찮을 수도 있다. 하지만 여러 타입의 Milk가 존재한다면? 혹은 앞으로 Milk 타입의 다른 구현 클래스가 추가된다면?

interface Milk
class SeoulMilk() : Milk
class MaeilMilk() : Milk
class KonkukMilk() : Milk

빵집마다 사용하는 Milk가 다를 수 있으므로 Milk마다 별도의 Bread 클래스를 정의해야 할 것이다.

// 내부에서 객체를 생성하는 방식
class SeoulMilkBread() : Bread { ... ]
class MaeilMilkBread() : Bread { ... }
class KonkukMilkBread() : Bread { ... }

하지만 빵을 만들기 위한 재료(의존성)는 Milk 외에도 여러 가지가 있다. 따라서, 이러한 방식은 엄청나게 많은 클래스를 만들게 된다.

반면, 외부에서 의존성을 전달하면 하나의 Bread 클래스를 상황에 따라 재사용 할 수 있게 된다.

// 의존성 주입 (생성자 주입)
class Bread(private val milk: Milk) { 
    // ... 
}

val bread1 = Bread(SeoulMilk())
val bread2 = Bread(MaeilMilk())
val bread3 = Bread(KonkukMilk())

// 프로퍼티(setter) 주입 방식
class Bread() {
	lateinit var milk: Milk
}
val bread = Bread()
bread.milk = SeoulMilk()
bread.milk = MaeilMilk()

의존성 주입의 장점

위에서 살펴본 DI의 장점은 클래스를 재사용 할 수 있다는 것이었다. (클래스 재사용성 or 유연성)

의존할 타입의 구체 클래스의 종류가 하나임이 확실하다면, DI의 이점은 없는 것일까? 당연히 다른 장점이 있다!

1. 생성자의 변경으로부터 보호할 수 있다

class SeoulMilk(val a: T1, val b: T2, val c: T3) : Milk
class Bread() {
    private val milk: Milk = SeoulMilk(T1(), T2(), T3())
}

// 생성자 시그니쳐 변경
class SeoulMilk(val a: S1, val b: T2, val c: W1, val d: S2) : Milk
class Bread() {
    private val milk: Milk = SeoulMilk(S1(), T2(), W1(), S2())
}

구현 클래스의 생성자는 변경될 가능성이 높다. 따라서, 클래스 안에서 객체를 생성할 경우 생성자가 변경되면 생성 코드를 일일이 수정해야 한다. 사용하는(클라이언트) 클래스가 하나라면 모르겠지만, 여러 클래스에서 의존하고 있다면 매번 함께 변경해줘야 할 것이다.

반면, DI를 사용하면 객체를 생성하는 코드를 별도의 클래스에서 관리한기 때문에 생성 코드를 한 번만 변경하면 된다. DI 프레임워크를 사용하면 변경하지 않아도 되는 경우도 있다.

2. 단위 테스트가 가능하다.

단위 테스트를 할 때는 특정 시나리오에서 클래스가 잘 동작하는지 확인하기 위해 테스트 용 임시 객체를 사용한다. 그런데, 객체를 내부에서 생성하면 테스트 환경에서 만든 임시 객체를 사용할 수 없다. 따라서, 클라이언트 클래스를 인터페이스에 의존하게 하고 외부에서 구현체를 주입하도록 바꾸면, 테스트 용 객체를 전달해서 단위 테스트를 할 수 있게 된다.

3. 객체 생성 코드가 분리되어 클래스가 간결해진다 (부가적인 요소)

Bread와 Milk 예시에서는 생성 방식이 간결하지만, 실제 개발 과정에서는 객체 생성 코드가 복잡한 경우도 있다. 이럴 때, 객체의 생성과 사용 코드를 분리하면 클라이언트 클래스의 코드 간결해진다. 하지만, 이런 DI 적용을 고려할 때 장점이 이것 하나인 상황이라면, DI를 굳이 적용하지 않아도 된다고 생각한다.


장점 정리

  • 클래스를 재사용 할 수 있다. (재사용성, 유연성)
  • 단위 테스트를 할 때 훨씬 편하다 (테스트 편의성)
  • 구현 클래스의 변경으로부터 보호할 수 있다. (ex: 생성자 시그니처 변경)
  • 객체 생성 코드가 분리되어 클래스가 간결해진다 (부수적 장점)

참조

+ Recent posts