Kotlin 다시 보기 (2)

‘빅 너드 랜치의 코틀린 프로그래밍’ 을 읽고 정리

인터페이스와 추상 클래스

  • abstact 함수(추상 함수) : 함수의 헤더만 선언하고 몸체(Body)의 구현 코드가 없는 함수

  • 객체간의 상속 관계가 없으면서, 주로 공통적인 속성이나 행동을 갖는 경우 : 인터페이스

  • 객체간의 상속 관계가 있으면서, 주로 인스턴스 생성이 필요 없는 부모 클래스가 필요한 경우 : 추상 클래스

인터페이스 구현하기

  • 인터페이스는 무엇을 해야 하는지 정의한 것이므로, 클래스로 구현(implement) 해야 한다.

  • 해당 인터페이스를 구현하는 클래스를 정의(구현)한 후에 이 클래스에서 인터페이스에 명시된 속성과 함수의 구현 코드를 제공하게 한다.

  • 인터페이스의 속성과 함수는 open 키워드를 지정하지 않아도 된다.

  • 인터페이스는 how가 아닌 what을 정의한다.

    • how는 인터페이스의 구현 클래스에서 정의
  • 다중 상속 가능

  • 속성 초기화, 값 지정 불가능

  • 인터페이스 타입의 매개변수를 가지므로 이 인터페이스를 구현하는 어떤 클래스의 인스턴스도 인자로 받을 수 있다

  • 인터페이스에 정의된 속성에 기본으로 구현된 getter와 함수에도 기본으로 구현된 몸체 코드를 제공할 수 있다.

    • damageRoll의 속성의 값을 지정하지 않을 경우 기본으로 구현된 getter에서 값이 지정됨
1
2
3
4
5
6
7
8
9
interface Fightable {
	var healthPoints: Int
	val damageRoll: Int
		get() = (O until dicecount).map {
			Random().nextInt(diceSides) + 1
    }.sum()
    
    fun attack(opponent: Fightable): Int
}

SAM(single abstract method) 인터페이스

  • 하나의 추상 메서드만 갖는 인터페이스 (코틀린이랑 상관없음)

    • 인터페이스 임으로, 하나의 타입이 된다 (컴파일러가 자동 타입 추론 및 에러 검출)

    • 함수형 인터페이스 라고도 불린다.

    • 1
      2
      3
      
      public interface OnClickListener { // 대표적 예, Java 코드
          void onClick(View v);
      }
      

SAM 변환

  • 자바 8 이전에는 함수형 인터페이스를 인자로 받는 Java 함수를 호출할 경우, 인터페이스를 구현한 익명 클래스 인스턴스를 만들어서 넘겨줘야 했다.
  • 자바 8 이후와 코틀린에서 인터페이스를 인자로 받는 Java 메서드를 호출 할 경우, 객체(object) 대신 람다를 넘길 수 있다. 이를 SAM 변환이라고 한다.

SAM 주의 사항

  • SAM 변환은 자바에서 작성한 인터페이스일 때만 동작
  • 코틀린에서 하나의 추상 메소드만 있는 인터페이스를 생성하고, 그 인터페이스를 인자로 받는 함수에 람다를 넘기려고 하면 오류가 발생
    • 이유는 코틀린에서는 함수를 파라미터로 사용할 수 있기 때문이다.
      • 코틀린에서는 함수 타입이라는 특별한 타입이 존재하기 때문
    • 만약 코틀린에서 SAM 변환을 사용하고 싶다면, 인터페이스 앞에 fun 키워드를 붙여주면 된다.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fun interface Practice{
    fun a()
} 

fun main(){
    doSomething{ println("a") } 	// 방법 1
    
    val practice = Practice{ println("a") }   // 방법 2
    doSomething(practice)
}

fun doSomething(practice: Practice){
    practice.a()
}

추상 클래스

  • 인터페이스와 비슷하게 추상 함수와 속성을 갖는다.

  • 구현 코드가 있는 일반 함수도 가질 수 있다.

  • 서브 클래스에서 추상 클래스의 속성과 함수를 상속받아 구현하도록 하는 것이 추상 클래스의 주 목적이다.

  • 추상 클래스는 주로 부모 클래스를 정의할 때 사용된다. (상속)

  • 인터페이스에 정의된 함수는 기본적으로 추상 함수지만, 추상 클래스에 정의되는 추상 함수에는 abstract 키워드를 지정해야 한다.

  • 일반 클래스는 하나의 추상 클래스만 부모 클래스로 가질 수 있지만, 인터페이스는 여러 개 구현 가능하다.

제네릭

image-20240517160018761.png

  • 제네릭: 타입을 미리 확정하지 않고, 사용되는 시점에서 특정 타입을 지정할 수 있도록 해주는 기법

  • 동일한 인터페이스 및 클래스, 함수의 정의를 재사용할 수 있어서 코드의 중복을 줄여준다.

  • 컴파일 시점에서 사용 타입의 적합성을 확인할 수 있으므로, 타입 안전을 보장해 준다. (타입 추론 가능)

  • List 는 ‘제네릭 인터페이스’로 정의 되었다.

    • 여기서 ‘List’ 는 raw 타입
    • <> 안에 지정된 타입을 제네릭 타입(generic type) 이라고 한다.
    • List<out E>
  • 제네릭 클래스의 인스턴스의 타입raw 타입제네릭 타입이 결합된 타입이 된다. ( 하나의 List<Int> 타입 )

제네릭 타입 정의하기

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class LootBox<T>(item: T) {
    private var loot: T = item
    var tmp = false

    fun fetch(): T? { // 제네릭 함수
        return loot.takeIf { tmp }
    }

    fun <R> fetch( lootModFunction: (T) -> R ): R? { // 복합 제네릭 타입 함수
         return loot.takeIf { tmp }
    }
}
  • 제네릭 타입 매개변수 : T / < >안에 지정됨

  • 보통 return 타입 매개 변수는 ‘R’로 표현

  • (T) -> R => 함수 타입

제네릭 타입 제약

  • 제네릭 타입 T: Loot를 지정하면, Loot 클래스 및 이것의 서브 클래스만 LootBox 클래스의 매개변수 타입으로 사용될 수 있다.
1
2
3
class LootBox<T: Loot>(itme: T) {

}

vararg

1
2
3
class LootBox<T: Loot>(vararg itme: T) {

}
  • vararg 키워드를 추가하면 매개변수가 배열(Array) 로 처리되므로 여러 개의 아이템을 인자로 전달할 수 있다.
    • 코틀린에서 기본 타입이 아닌 Arrays 라는 참조 타입으로 배열을 지원한다. (코틀린은 모두 참조 타입)
      • 여기서 배열은 컬렉션 타입이다.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class MyDataClass constructor(vararg val numbers: Int) {
    // ...
}

val myData1 = MyDataClass(1, 2, 3)
val myData2 = MyDataClass() // 빈 배열 전달 가능
val myData3 = MyDataClass(4, 5) 

val numbers = intArrayOf(1, 2, 3)
val myData = MyDataClass(*numbers) // 배열을 펼쳐서 전달
  • 기본 생성자에서의 vararg

    • 코틀린의 기본 생성자에서 vararg를 사용하면, 생성자에 가변적인 개수의 인자를 전달하여 객체를 초기화할 수 있다.
  • vararg 사용 시 주의 사항:

    • 하나의 생성자에 vararg 매개변수는 최대 하나만 사용할 수 있다.

    • vararg 매개변수는 항상 마지막 매개변수로 와야 한다.

    • vararg 매개변수는 내부적으로 배열로 처리된다.

  • 활용 예시:

    • 다양한 개수의 인자를 받는 함수: 예를 들어, 평균을 계산하는 함수를 정의할 때 vararg를 사용하면 여러 개의 숫자를 인자로 받아 처리할 수 있다.

    • 배열을 펼쳐서 전달: 스프레드 연산자(*)를 사용하여 배열을 펼쳐서 vararg 매개변수에 전달할 수 있다.

in과 out

  • 제네릭 클래스의 인스턴스는 raw 타입과 제네릭 타입이 결합된 것이 자신의 타입이 된다.

    • <>로 나타낸 제네릭 타입 간의 부모-자식 관계가 있더라도 컴파일러가 인식하지 못한다.

      • 개발자 입장에서 불편함
    • 이 불편을 해결 방법이 in과 out 이다.

  • out 키워드를 지정하면, 두 제네릭 타입 간의 부모-자식 타입 관계를 ‘그대로’ 컴파일러가 고려해준다.

    • List의 타입도 List<out E>로 되어 있다.

    • 제네릭 타입 매개변수out 키워드를 지정한 것을 공변형(covariance)

  • in 키워드를 지정하면, 제레릭 타입 매개변수를 포함한 클래스나 인터페이스의 관계가 제네릭 타입 매개변수부모-자식 관계와 ‘반대’ 로 된다.

    • ‘동물’ 타입이 ‘사자’ 타입의 부모인 경우

    • ‘Pet<동물> 타입’이 ‘Pet<사자> 타입’의 자식 타입으로 간주된다.

    • 제네릭 타입 매개변수에 in 키워드를 지정한 것을 반공변형(contravariance)

reified 키워드

  • 컴파일된 JVM 바이트 코드에서는 제네릭 타입 매개변수의 정보가 수록되지 않고 소거된다.

    • List<Int>, List<Double> 모두 JVM 바이트코드에는 raw 타입 List 로 처리된다.
    • 실체화(reification): 코틀린은 이 문제를 해결하기 위해 제네릭 타입 매개변수를 컴파일러가 실제 타입으로 변경해주는 기능을 지원
      • 제네릭 타입 매개변수의 실체화(reification)
  • 제네릭 타입 매개변수가 지정된 클래스는 타입 검사에 사용될 수 없다.

    • if (l is List<String>) ⇒ 컴파일 에러
  • reified 키워드 를 사용하면 제네릭 매개변수로 전달된 인자의 타입과 인스턴스의 타입을 런타임 시에 검사할 수 있게 된다. (inline 키워드와 같이 사용)

    • 타입 매개변수의 실체화는 inline 키워드가 지정된 인라인 함수에서만 가능하다.
1
2
3
inline fun <reified T> check(f1: () -> T): T {
	// ~~~
}

확장

  • 기존 타입(클래스 or 인터페이스)의 정의를 직접 변경하지 않고 새로운 기능을 추가해준다.

  • JVM 바이트 코드는 코틀린 확장 함수가 static 메서드로 된다.

  • 각 타입에 대한 확장 함수나 속성을 포함하는 코틀린 표준 라이브러리 파일들은 타입 이름 끝에 s를 붙인 파일 이름을 갖는다.

    • Strings.kt도 그렇고 Sequences.kt, Ranges.kt, Maps.kt
  • 코틀린은 확장 함수를 굉장히 많이 사용하여 핵심 API를 정의하고 있다.

  • 표준 라이브러리의 크기가 작으면서도 많은 기능을 제공한다.

  • 확장을 사용하면 함수나 속성을 하나만 정의하여 여러 타입에 같은 기능을 제공할 수 있으므로 메모리 효율 상승

확장 함수 정의하기

  • 확장 함수를 추가할 타입을 수신자 타입 이라고 한다. 그리고 이것을 지정해야 한다.

  • this 는 확장 함수가 호출된 ‘수신자 객체’를 뜻한다.

  • 클래스의 자식 클래스를 만들 수 없거나, 해당 클래스의 정의를 변경할 수 없을때(외부 라이브러리), 주로 확장을 사용한다.

제네릭 확장 함수

1
2
3
fun <T> T.easyPrint() : T {
 //
}
  • 제네릭 확장 함수인 ‘easyPrint’는 어떤 타입에도 사용 가능하며 타입 정보도 유지된다.

확장 속성

  • 확장 속성은 backing field를 갖지 않으므로 초기화할 수 없다.
    • var 대신 val을 지정하고, 원하는 값을 반환하는 get 을 반드시 정의해야 한다.
    • 속성에서 반환될 값을 지정해줘야 한다.
    • backing field에 데이터가 저장된다.
1
2
val String.numVowels
    get() = count { "aeiouy".contains(it) }

infix 키워드

  • infix 키워드하나의 인자를 갖는 확장 함수와 클래스 함수에 모두 사용할 수 있다.
  • infix(중위) 함수는 함수 호출 사이의 점(.) 과 괄호를 생략하게 해준다. 일반 함수 호출 처럼 호출도 가능
1
2
3
// 같은 함수 호출
null.printWithDefault("기본 문자열")
null printWithDefault "기본 문자열"

함수형 프로그래밍

  • 함수형 프로그래밍은 컬렉션을 사용하도록 설계된 고차 함수가 반환하는 데이터에 의존한다.

    • 고차 함수의 연쇄적 호출
    • 함수 타입의 역할 => 다른 함수를 값으로 정의해주거나, 인자로 받거나 반환하는 일급 함수을 지원
  • 함수형 프로그램을 구성하는 함수의 유형에는 변환(transform), 필터(filter), 결합(combine) 이 있다.

  • 코틀린은 다중 프로그래밍 방식으로 상황에 따라, 객체지향과 함수형 프로그래밍을 혼합하여 코드를 작성 가능

  • 일급 함수 (First-Class Function)

    • 정의: 일급 함수는 다른 변수와 동일하게 다룰 수 있는 함수를 의미
      • 변수화, 매개변수화, 반환 값으로 사용가능
    • val func: (Int) -> Int = { it * 2 }
    • 람다(익명 함수, 이름 없는 함수) 형태를 띔
  • 고차 함수 (Higher-Order Function)

    • 정의: 고차 함수는 ‘일급 함수’를 인자로 받거나 반환하는 함수를 의미
    • val doubled = numbers.map { it * 2 } // map은 고차 함수

변환 (transform)

  • 변환 함수는 입력 컬렉션에 저장된 모든 요소를 읽는다.

  • 지정 변환 함수를 실행하여 컬렉션의 각 요소나 항목을 변환한 후, 변경된 ‘새로운 컬렉션’을 반환한다.

    • 입력 컬렉션은 변경되지 않는다.
    • 임시로 새로운 객체 생성 후, 다음 연쇄 호출된 함수로 넘김
  • map, flatMap

    • 제네릭 타입 매개변수를 사용함으로 인자의 타입과 반환 타입을 다르게 처리할 수 있다.
  • <T, R> Iterable<T>.map(transform: (T) -> R): List<R>

  • flatMap 함수는 인자로 전달된 변환 함수의 결과로 산출된 모든 요소를 하나의 컬렉션으로 생성하여 반환한다.

    • listOf( listOf(1,2,3), listOf(4,5,6) ).flatMap { it } // [1,2,3,4,5,6]

필터 (filter)

  • 필터 함수는 컬렉션의 각 요소를 검사하고, true or false를 반환하는 술어 함수를 인자로 받는다.

  • filter 하나

  • true를 반환하는 요소만 결과 컬렉션에 해당 요소가 추가 된다.

결합 (combine)

  • 서로 다른 컬렉션 인자로 받아서 모든 요소들이 합쳐진 새로운 컬렉션을 생성한다

  • zip, fold

  • fold : 최초 누적값을 인자로 받으며, 이 값은 각 요소에 대해 호출되는 익명 함수의 결과값으로 변경된다.

왜 함수형 프로그래밍일까?

  • 처리 중간에 값을 축적하는 변수들이 내부적으로 정의되므로, 상태를 보존하는 변수들을 줄일 수 있다.

  • 자동으로 축적 변수에 추가되므로, 코드 에러 발생 위험이 줄어든다.

    • Side Effect 제거, 불변성 증가, 테스트 용이성 향상
  • 새로운 연산이 필요하면, 함수의 연쇄 호출에 추가하면 된다.

시퀀스 (sequence)

  • 컬렉션 타입들은 eager collection( 조기 컬렉션 ) 이라고 한다.

    • 인스턴스가 생성될 때는 자신이 포함하는 요소나 항목이 추가되므로 바로 사용될 수 있기 때문이다.
  • 지연 컬렉션(lazy collection)라는 내장된 타입 : sequence, 필요할 때만 값이 생성된다.

  • 시퀀스를 사용할 때는 새로운 값이 요청될 때마다 참조되는 반복자 함수를 정의한다.

  • 함수형 프로그래밍에는 보통, 새로운 컬렉션을 자주 생성해야 한다.

  • 그러나 시퀀스를 사용하면 대형 컬렉션에 사용할 수 있는 매커니즘을 제공한다.

    • 시퀀스로 검사할 항목개수의 상한값을 정의할 필요가 없기 때문이다.
    1
    2
    3
    
    val oneThousandPrimes = generatesequence(3) { value ->
    	value + 1
    }.filter { it.isPrime() }.take(1000)
    
  • 그러나 수십만 개의 요소를 가질 정도로 컬렉션이 커지면 컬렉션 타입을 변경하는 데따른 성능 향상이 중요해질 수 있다.

    • 이 경우에는 asSequence 함수를 사용해서 List를 시퀀스로 쉽게 변환할 수 있다.