정의
코틀린은 클래스 상속이나 데코레이터 같은 설계 패턴 없이, 새로운 기능으로 클래스와 인터페이스를 확장할 수 있는 기능을 제공한다. 이는 확장(extensions)이라는 특수 선언을 통해 이루어진다.
확장의 장점은 수정할 수 없는 타사의 라이브러리 클래스 또는 인터페이스에 대한 새 함수를 작성할 때 빛을 본다. 이렇게 만들어진 함수는 원래 클래스의 메서드 인것 처럼 일반적인 방식으로 호출할 수 있다.
확장 기능을 2가지이다.
- 확장 함수 : 위에서 설명한 것 처럼 새 함수를 작성하는 메커니즘
- 확장 속성 : 기존 클래스에 대한 정의한 새 속성
확장 함수
확장 함수를 선언하려면 확장되는 유형을 참조하는 리시버 타입(수신자 유형)을 해당 이름 앞에 붙인다.
(리시버 타입 == 확장할 타입(ex 클래스)의 이름)
fun MutableList<Int>.swap(index1: Int, index2: Int){
val temp = this[index1] //this는 리시버(MutableList<Int>)의 인스턴스이다.
this[index1] = this[index2]
this[index2] = temp
}
MutableList <Int> 타입에
swap확장 함수를 추가해봤다. 함수의 기능은 두 인덱스의 원소를 바꾸는 것이다. 확장함수를 사용한다면 다음과 같을 것이다.
val l = mutableListOf(1, 2, 3)
l.swap(0, 2)
이 함수는 Int뿐만 아니라 MutableList <T>에도 적용되므로 제네릭으로 만들 수 있다.
fun <T> MutableList<T>.swap(index1: Int, index2: Int){
val temp = this[index1] //this는 리시버(MutableList<T>)의 인스턴스이다.
this[index1] = this[index2]
this[index2] = temp
}
전체 코드를 보면 다음과 같다.
fun main(){
val llist = mutableListOf<Int>(1, 2, 3, 4, 5)
println(llist)
llist.swap(0, 2)
println(llist)
}
fun <T> MutableList<T>.swap(index1: Int, index2: Int){
val tmp = this[index1]
this[index1] = this[index2]
this[index2] = tmp
}
-결과-
[1, 2, 3, 4, 5]
[3, 2, 1, 4, 5]
정적인 확장 결정
확장은 실제로 확장할 클래스를 변경하지 않는다. 새 멤버를 추가하기보단, 그 타입의 변수에 점 부호로 호출할 수 있는 새 함수(임시)를 만드는 것이다.
확장 함수는 리시버 타입(수신자 유형)에 따라 동적으로 확장 함수를 결정하지 않는다. 함수 호출 식의 타입에 따라 호출할 확장함수를 결정한다. 이로 미루어보아 알 수 있는 것은 확장 함수는 정적으로 전달된다는 것이다.
open class C
class D : C()
fun C.foo() = "c"
fun D.foo() = "d"
fun printFoo(c: C){
println(c.foo())
}
printFoo(D())
위 예제를 보고 추측해보자. D는 C를 상속하고 있기 때문에 printFoo() 함수에 D 인스턴스가 들어갈 수 있다. D의 foo() 메서드를 호출했기 때문에 따라서 결과는 "d"라고 볼 수 있다.
틀렸다
이 예는 "c"를 출력한다. 다시 한번 위 내용을 보자. 확장함수는 리시버 타입(수신자 유형)과 상관없이 정적으로 전달된다.
printFoo() 함수의 패러미터 c의 타입이 클래스 C 이므로 클래스 C 타입에 대한 확장 함수를 호출하기 때문이다. 디컴파일된 자바 코드를 보자
public final class MainKt {
public static final void main() {
printFoo((C)(new D()));
}
// $FF: synthetic method
public static void main(String[] var0) {
main();
}
//오버로딩
@NotNull
public static final String foo(@NotNull C $this$foo) {
Intrinsics.checkNotNullParameter($this$foo, "$this$foo");
return "c";
}
//오버로딩
@NotNull
public static final String foo(@NotNull D $this$foo) {
Intrinsics.checkNotNullParameter($this$foo, "$this$foo");
return "d";
}
public static final void printFoo(@NotNull C c) {
Intrinsics.checkNotNullParameter(c, "c");
String var1 = foo(c);
System.out.println(var1);
}
}
public class C {
}
public final class D extends C {
}
printFoo()를 호출하고 있는 main() 메서드를 보자. printFoo()의 아규먼트로 인스턴스D를 줬는데 C로 강제 타입 변환해서 주고 있다. 따라서 넘어간 D인스턴스의 타입은 C이므로 오버 로딩된 메서드 중 C타입을 패러미터로 쓰고 있는 foo() 함수를 호출하는 것이다.
상속의 특징을 생각하면 편하다.
public class Main {
public static void main(String[] args) {
Car c = new Cow();
c.printCar();
c.printCow();//에러
}
}
class Car{
public void printCar(){
System.out.println("Car");
}
}
class Cow extends Car{
@Override
public void printCar(){
System.out.println("Bar");
}
public void printCow(){
System.out.println("cow");
}
}
출력 결과 : Bar
Cow인스턴스를 Car타입의 c변수에 넘겨줬으므로 오버 라이딩된 printCar()는 호출할 수 있지만 printCow()는 Car클래스에 구현돼있지 않으므로 호출할 수 없다.
자식 타입으로 넘겨줬더라도 받는 타입이 부모 타입이면 인스턴스의 멤버는 자식이 오버 라이딩하지 않는 한 부모의 멤버를 따라간다.
클래스에 멤버 함수가 있고 동일한 리시버 타입(수신자 유형), 동일한 이름을 갖고 주어진 인수에 적용 가능한 확장하수가 정의된 경우 항상 멤버 함수가 이긴다. (확장 함수 vs 멤버 함수 = 확장 함수)
class C{
fun foo() { println("member") }
}
fun C.foo() {println("extension")}
출력 결과 : member
만약 메서드 시그니처가 다르다면?
class C{
fun foo() { println("member") }
}
fun C.foo(i: Int) {println("extension")}
오버 로딩된 다른 함수이므로 C.foo(1) 입력 시 "extension"을 출력한다.
null가능 리시버
확장이 null가능 리시버 타입(리시버 타입)을 가질 수 있도록 정의할 수 있다. 이 확장은 객체 변수가 null일 때도 호출할 수 있고, 바디에서 this==null로 이를 검사할 수 있다. 이로써 코틀린에서 null체크 없이 toString 호출할 수 있도록 한다.
null가능 리시버 타입으로 정의하는 방법은 safeCall 연산자 ?. 를 이용하면 된다
fun Any?.toString(): String{
if(this == null) return "null"
// null검사 후 'this'는 null이 아닌 유형으로 자동 캐스트 되므로 아래의 toString()는
// Any클래스 멤버 함수로 결정됩니다.
return toString()
}
확장 프로퍼티
함수처럼 프로퍼티도 확장을 지원한다.
val <T>List<T>.lastIndex: Int
get() = size - 1
확장 프로퍼티에 대한 초기화는 할 수 없다. 실제로 클래스에 멤버를 추가하는 것이 아니기 때문이다.
또한 실제 멤버가 아니기 때문에 지원 필드를 사용하기 어렵다.
초기화 대신 명시적으로 getter/setter을 제공해서 정의할 수 있다.
fun main(){
D().m()
}
//C 클래스 정의
class C(){
var a = 0
set(value){
if(value > 3)
field = value
}
}
//C클래스를 확장한 b 프로퍼티 정의
class D(){
var C.b: Int
get() = 1
set(value) {
if(value > 5)
field = value //에러!
}
//b프로퍼티 출력
fun m(){
print(C().b)
}
}
-결과-
1
5 이상의 값을 할당한다면 에러를 볼 수 있다.
컴페니언 오브젝트 확장
컴페니언 오브젝트도 확장 함수와 프로퍼티를 정의할 수 있다.
class MyClass{
companion object{ }
}
fun MyClass.Companion.foo(){ //foo확장함수 정의
//...
}
클래스 이름만 한정자로 사용해서 호출 가능하다.
MyClass.foo()
MyClass().foo() // <- 될것 같지만 안된다. 인스턴스에 foo() 멤버함수를 호출하기 때문이다.
확장의 범위
대부분 패키지와 같은 최상위 수준에 확장을 정의한다.
package foo.bar
fun Baz.goo() { ... }
확장 함수를 선언한 패키지 밖에서 사용하려면 사용 위치에서 확장함수를 임포트 해야 한다.
package com.example.usage
import foo.bar.goo //goo의 모든 확장을 임포트. 확장함수 명으로 임포트 해야한다.
fun usage(baz: Baz){
baz.goo()
}
멤버로 확장 선언하기
중첩 클래스 처럼 중첩 확장이 가능하다. 그런 확장자 안에선 한정자 없이 접근할 수 있는 암묵적인(implicait) 리시버 객체 멤버가 존재한다.
함수를 선언하고 있는 클래스의 인스턴스를 디스패치 리시버라고 부르며, 확장 메서드의 리시버 타입 인스턴스를 확장 리시버라 부른다.
class D {
fun bar() {...}
}
class C { // 메모리에 적재되면 디스패치 리시버
fun baz() {...}
fun D.foo() { //메모리에 적재되면 확장 리시버
bar() //한정자 없이 D.bar() 호출
baz() //같은 클래스 이므로 C.baz() 호출
}
fun caller(d: D) {
d.foo()
}
}
만약 디스패치 리시버와 확장 리시버의 멤버 간 이름 충돌이 있을 경우 확장 리시버가 우선이다. 디스패치 리시버의 멤버를 참조하려면 this구문을 사용하면 된다.
class C {
fun D.foo(){
toString() //D.toString() 호출
this@C.toString() //C.toString 호출
}
}
클래스 멤버로 선언된확장을 open으로 선언할 수 있고 하위 클래스에서 오버 라이딩(상속)이 가능하다. 디스패치 리시버 타입에 따라 확장 함수를 선택한다. 하지만 확장 리시버 타입에 대해선 정적이다.
open class D {}
class D1 : D() {}
open class C {
open fun D.foo(){
println("D.foo in C")
}
open fun D1.foo(){
println("D1.foo in C")
}
fun caller(d: D){
d.foo()
}
}
class C1 : C() {
override fun D.foo(){
println("D.foo in C1")
}
override fun D1.foo() {
println("D1.foo in C1")
}
}
C().caller(D())
C1().caller(D())
C().caller(D1())
한 번 위 코드의 결과를 생각해 보자
정답
위 함수의 결과는
C에서 caller함수를 호출, 아규먼트로 D 인스턴스를 넘겼고 C 클래스에서 D 인스턴스의 확장 함수 foo가 구현돼있으므로
"D.foo in C"가 출력된다.
C1에서 caller함수를 호출, 아규먼트로 D 인스턴스를 넘겼고 C1 클래스에서 D 인스턴스의 확장 함수 foo가 구현돼있고 디스패치 리시버를 동적으로 선택하므로
"D.foo in C1"이 출력된다.
C에서 caller함수를 호출, 아규먼트로 D1 인스턴스를 넘겼고 C 클래스에서 D1 인스턴스의 확장함수 foo가 구현되어 있지만 확장 리시버를 정적으로 선택하므로 이미 정의돼있는 d:D에서 D.foo()를 호출한다.
따라서 "D.foo in C"가 출력된다.
참고
Extensions | Kotlin
kotlinlang.org
'코틀린' 카테고리의 다른 글
봉인된 클래스 및 인터페이스(Seald Class & Interface) (0) | 2023.04.10 |
---|---|
데이터 클래스(Data Class) (0) | 2023.03.20 |
가시성 수식어(Visibility modifiers) (0) | 2023.02.02 |
Functional (Single Abstract Method) Interface (1) | 2023.02.02 |
인터페이스 (0) | 2023.02.01 |