❓추상 클래스 (abstract class)
추상 클래스란 클래스의 추상적인 부분(==공통적인 부분)을 모아놓은 클래스이며, 그 자체로 인스턴스화는 불가능한 클래스다. 왜 인스턴스화를 할 수 없을까? 완전하게 구현되어 있지 않으니까!
추상 클래스는 프로퍼티나 메서드를 abstract로 가지고 있을 수 있고, abstract로 된 프로퍼티나 메서드는 미완성된 부분이기 때문에 이 추상 클래스를 상속받는 클래스에서 반드시 완전하게 구현해주어야 한다.
코틀린에서 어떤 클래스의 상속을 허용하기 위해서는 크래스 앞에 open 변경자를 붙어야 한다. 그와 더불어 오버라이드를 허용하고 싶은 메서드나 프로퍼티 앞에도 open 변경자를 붙여줘야 한다.
하지만❗ 추상 멤버는 항상 열려있기 때문에 추상 클래스는 open 키워드를 붙여주지 않아도 상속이 가능하다.
추상 클래스는 abstract 메서드가 하나 이상 있는 클래스로 볼 수 있다. 추상 클래스를 상속받는 자식 클래스는 반드시 추상클래스의 abstract 메서드를 구현해야 한다.
추상 클래스 Figure를 다음과 같이 정의했다고 하자.
추상 프로퍼티 1개와 추상 메서드 1개, 일반 메서드 1개를 가지고 있다. 그러면 Figure를 상속받은 클래스는 1개의 추상 프로퍼티, 1개의 추상 메서드를 구현해야한다.
추상 프로퍼티와 메서드를 가지고 있기 때문에 Figure는 인스턴스화 할 수 없다.
그래서 Figure를 상속받은 Rectangle 클래스는 1개의 추상 프로퍼티, 1개의 추상 메서드를 구현해주었다.
그리고 위의 메인을 실행시키면 결과가 어떻게 나올까?
width와 printArea() 함수는 부모 클래스인 Figure에 정의되어 있는 것을 사용한다. height을 4로 설정해주었고, getArea() 함수를 구현한 코드를 보면 12가 나올 것을 예상할 수 있을 것이다.
❓ 인터페이스 (interface)
코틀린 인터페이스는 자바 8 인터페이스와 비슷하다. 코틀린 인터페이스 안에는 추상 메서드뿐 아니라 구현이 되어있는 메서드도 정의할 수 있다. 다만 인터페이스에는 아무런 상태(필드)도 들어갈 수 있다.
인터페이스 역시 추상 클래스처럼 바디가 구현되어 있는 메서드를 가질 수 있다고 했다. 앞에 default를 붙여야 하는 자바 8과는 달리 코틀린에서는 키워드를 붙일 필요가 없다. 그냥 메서드 본문을 함수의 시그니처 뒤에 붙여주면 된다.
근데 인터페이스는 한 가지 문제?점이 있다. 바로 인터페이스를 구현하는 클래스가 있다고 할 때 인터페이스에 새로운 메서드가 추가되면 구현하고 이를 구현하는 클래스도 추가된 메서드를 반드시 오버라이드 해줘야 한다는 점이다.
추상 클래스로 만들었던 Figure를 인터페이스로 다시 정의했다.
같은 역할을 하는 함수와 프로퍼티를 가지고 있지만 추상 클래스와의 차이는 abstract 키워드를 붙이지 않았다는 것이다.
인터페이스에서 초기화 된 프로퍼티를 사용하고 싶다면 width처럼 getter를 사용해야 한다.
인터페이스에 초기화가 되지 않은 height은 반드시 오버라이딩하여 사용해야 한다. 또 구현되어 있지 않은 getArea함수 역시 오버라이드 해줘야한다.
Figure 인터페이스를 구현한 Rectangle 클래스다. 데이터 클래스를 상속 받았던 코드와 정확히 일치한다.
그리고 앞에서 봤던 main을 하나도 고치지 않고 실행시키면?
역시 결과는 12가 잘 나온다.
💡 공통점/차이점
공통점
- 인스턴스화가 불가능하다.
- 추상 클래스와 인터페이스 모두 추상 메서드, 추상 프로퍼티, 일반 메서드를 가질 수 있다.
차이점
- 추상 클래스에서는 abstract 키워드를 붙여줘야 하지만 인터페이스에는 붙여주지 않아도 된다.
- 추상 클래스에서 초기화된 프로퍼티를 갖고 싶다면 직접 값을 초기화 해줄 수 있지만 인터페이스에서는 getter를 사용해야 한다.
- 클래스는 인터페이스를 원하는 만큼 개수 제한 없이 마음대로 구현할 수 있지만, 클래스는 오직 하나만 확장할 수 있다.
📃 여담
근데 이렇게만 보면 기능적인 측면에서 어떤 차이점이 있는 것인지 잘 모르겠다. 내가 이해한 내용을 제이슨에게 물어봤는데 끝없는 물음표의 나락으로 떨어졌다. 시간이 괜찮다면 2시간동안의 여정에 동참해보자.
프로퍼티?
내가 생각한 프로퍼티는 "필드와 getter, setter를 함께 부르는 말"이었다.
class D {
val a: Int = 0
}
그럼 a는 무엇이라고 부를까? getter는 가지고 있지만 val이기 때문에 setter가 없는데 프로퍼티일까? 첫 계단부터 험난하다. (이럴 때 나는 책을봐..)
코틀린 인 액션 2장 71페이지를 보면 다음과 같이 이야기하고 있다.
자바에서는 필드와 접근자를 한데 묶어 프로퍼티라고 부른다. 코틀린은 프로퍼티를 언어 기본 기능으로 제공하며, 코틀린 프로퍼티는 자바의 필드와 접근자 메서드를 완전히 대신한다. val로 선언한 프로퍼티는 읽기 전용이며, var로 선언한 프로퍼티는 변경 가능하다.
여기서 주의 깊게 봤으면 하는 부분은 두 가지다.
1. 프로퍼티는 자바의 필드와 접근자 메서드를 한데 묶은 것을 말한다.
필드를 읽기 위한 getter를 제공하고, 필드를 변경하게 허용해야 할 경우 setter를 추가 제공할 수 있다는 말로 미루어 볼 때 프로퍼티 == 필드 + getter + setter 가 아니라는 말이다.
2. val로 선언한 프로퍼티는 읽기 전용이다.
val은 불변이 아니다. 읽기 전용 프로퍼티다. 참조가 불가능한 것이지 참조 대상 자체의 내부 값들이 변경 불가능한 것은 아니라는 것이다.
b 누구냐 넌
class D {
private val a: Int = 0
private val b: Int
get() = 0
}
그럼 a와 b는 뭐라고 부를까? 둘 다 프로퍼티일까? 사실 a는 위에서 봤으니 b가 관건이다.
일단 모르겠으니 무적의 디컴파일을 해보자.
public final class D {
private final int a;
private final int getB() {
return 0;
}
}
자바 코드로 확인해봤을 때 a는 필드가 있는데 b는 필드가 없다. 그래서 어떻게 보면 a는 변수, b는 함수로 볼 수도 있을 것 같다.(feat JJJ..)
그러면 다시 코틀린 코드로 돌아와보자. 디컴파일된 자바 코드를 보면서 b는 함수로 볼 수 있을 것 같다고 했다. 즉, 정적으로 값을 정해놓는 것이 아닌 동적으로 계산을 해서 결과를 돌려준다. 그래서 코틀린에서는 b와 같은 친구를 계산된 프로퍼티(Computed Property)라고 부른다.
Computed Property
계산된 프로퍼티에 대해 조금 더 알아보자. 자바에서 코틀린으로 215페이지를 보면 다음과 같이 말한다.
계산된 프로퍼티는 필드로 뒷받침되지 않는 프로퍼티를 말한다.
잠깐! 코틀린에서는 필드라는 개념이 없는데 이게 무슨 말이야? 라고 할 수 있다. 나도 그랬으니까. 바로 위에서 클래스 D를 디컴파일 했을 때를 생각해보면 조금 더 이해가 빠를 수 있다. a는 필드가 생겼지만 b는 필드가 생기지 않았었다. 그러면 "필드로 뒷받침되지 않는"이라는 말이 더 와닿을 것이다.
내가 이해한바로 조금 더 쉽게 풀어말하면 필드가 없는 프로퍼티 혹은 값을 저장하지 않는 프로퍼티라고 할 수 있을 것 같다. 또, 계산된 프로퍼티는 생성자 밖에서 정의한다.
자바는 프로퍼티 접근 메서드와 다른 유형의 메서드를 구분하지 않는다. 반면 코틀린에서는 프로퍼티를 멤버 함수와 다르게 취급한다.
계산된 프로퍼티가 단순히 설탕을 끼얹은 메서드일 뿐이라면 언제 계산된 프로퍼티를 선택해야만 하고 언제 메서드를 선택해야만 할까?
대략적인 규칙은 같은 타입에 속한 다른 프로퍼티에만 의존하고 계산 비용이 싼 경우에는 계산된 프로퍼티를 택하라는 것이다.
다음과 같은 코드를 보자.
data class PersonWithProperties(
val givenName: String,
val familyName: String,
val dateOfBirth: LocalDate
) {
val fullName get() = "$givenName $familyName"
}
PersonWithProperties 클래스는 givenName과 familyName을 가지고 있다. 그러면 fullName을 가지고 있을 필요 없이 계산하여 만들어낼 수 있고, 이 때 드는 비용도 크지 않을 것이다.(비용이 큰지 안큰지는 어떻게 알 수 있는걸까?)
그래서 fullName을 계산된 프로퍼티로 처리하는 것이다.
그리고 인텔리제이는 fullName이 프로퍼티라는 사실을 인식하지 못한다. 만약 fullName을 다음과 같이 작성하고 Convert function to property를 실행하면 다음과 같이 바뀐다.
fun fullName() : String {
return "$givenName $familyName"
}
val fullName: String
get() {
return "$givenName $familyName"
}
추상클래스는되고 인터페이스는 안되는 것
또 재미있는 것을 한번보자.
interface C {
// private val a: Int = 0
private val b: Int
get() = 0
}
abstract class B {
private val a: Int = 0
private val b: Int
get() = 0
}
둘 다 b를 가질 수 있지만, 인터페이스 C는 위 코드처럼 a를 선언할 수 없는 반면 추상 클래스 B는 가질 수 있다. 앞서서 a와 b의 차이를 확인해봤다. a는 프로퍼티, b는 계산된 프로퍼티라고 부른다고 했고, 계산된 프로퍼티는 필드가 없다고 했다.
그래서 이를 연결시켜보면 인터페이스와 추상클래스의 공통점과 차이점을 알 수 있다.
공통점은 계산된 프로퍼티를 가질 수 있다는 것이고
차이점은 인터페이스는 필드로 뒷받침되는 프로퍼티를 가질 수 없지만(값을 저장할 수 있는 필드가 없다), 추상클래스는 필드로 뒷받침되는 프로퍼티를 가질 수 있다(값을 저장할 수 있는 필드가 있다)는 것이다.
(여기서 굳이 일반 클래스가 아니라 추상클래스로 예를 든 이유는 이 이야기가 나오게 된 근본적인 의문이 인터페이스와 추상클래스의 차이였기 때문이다. 이 부분만 떼놓고 본다면 일반 클래스로 작성해도 큰 문제가 없다.)
언제, 어디서
추상클래스는 이를 상속할 각 객체들의 공통점을 찾아 추상화시켜 놓은 것으로, 상속 관계를 타고 올라갔을 때 같은 부모 클래스를 상속하며 부모 클래스가 가진 기능들을 구현할 때 사용한다. 그래서 추상클래스는 같은 종류나 행동들을 구현할게 많을 때 쓰면 좋을 것 같다.
인터페이스를 사용하는 이유 중 하나는 동시 개발이 가능하다는 점이다. 인터페이스의 메서드들은 내부가 구현되어 있지 않아도 입력값과 결과값이 결정되어 있기 때문에 해당 메서드를 사용하는 다른 곳을 구현하면서 함수 자체도 구현할 수 있다.
그래서 내 생각에는 추상 메서드만 선언할 것이라면 인터페이스를, 다른 일반 메서드나 값의 저장이 필요한 프로퍼티도 선언할 것이라면 추상클래스를 쓰는 것이 좋지 않을까 생각한다.
인터페이스에서 디폴트 메서드를 구현할 수 있는 이유
인터페이스에서 디폴트 메서드를 구현할 수 있도록 만든 이유는 무엇일까?
나는 하위 호완성 때문이라고 생각한다.
내가 구현한 인터페이스를 다른 여러 클래스가 구현하고 있다고 가정해보자. 새로운 기능을 추가할 필요가 생겨 이 인터페이스에 메서드를 추가해야만 하는 상황이 생겼다. 그냥 메서드를 추가해버리면 이 인터페이스를 구현하여 사용하고 있는 모든 곳에서 에러가 팡팡 터질 것이다.
인터페이스에서 디폴트 메서드를 구현 할 수 있도록 해놓는다면 앞에서 만난 문제를 피할 수 있고(추가한 함수가 이미 구현되어 있는 상태이기 때문에 인터페이스를 구현하고 있는 클래스에서 필수적으로 오버라이드 할 필요가 없다.), 이러한 이유로 조금 더 쉽게 유지보수 할 수 있지 않을까?
Backing Field 와 Backing Property?
커스텀 setter를 작성하다보면 신기한 것을 발견할 수 있다.
똑똑한 인텔리제이의 자동완성을 보면 field가 있는 것을 볼 수 있다. A클래스 어디에도 field는 선언되어 있지도 않는데 이 친구는 어디서 나왔을까?
class A {
var a: Int = 0
set(value: Int){
field = value
}
}
커스텀 setter를 작성을 다한 뒤 디컴파일 해보자.
public final class A {
private int a;
public final int getA() {
return this.a;
}
public final void setA(int value) {
this.a = value;
}
}
필드 a가 생겼고, getter, setter가 생성된 것을 볼 수 있다. 이렇게 프로퍼티의 값을 저장하기 위한 필드를 backing field라고 부른다.
backing field에서 커스텀 getter, setter를 만들 때 제약이 있다. getter는 반환 타입이 반드시 프로퍼티의 타입과 같아야 하기 때문인데 다음 코드를 보자.
class A {
private var a: MutableList<Int> = mutableListOf()
}
a는 MutableList다. 이 경우 외부로 값을 전달하여 조작할 때 원하지 않더라도 내부 값이 변경될 수 있다. 이런 경우 다음과 같은 backing property를 사용하면 List로 반환할 수 있다.
class A {
private var _a: MutableList<Int> = mutableListOf()
val a: List<Int>
get() = _a.toList()
}
그리고 보통 언더바를 붙여준다.
class A {
private var _a: MutableList<Int> = mutableListOf()
val a: List<Int>
get() = _a.toList()
}
class A {
private var _a: MutableList<Int> = mutableListOf()
val a: List<Int> = _a.toList()
}
두 방식의 차이에 대해서도 알아두면 좋을 것 같다. 첫 번째 방식을 사용하면 getter를 이용하여 _a의 값을 가져올 수 있다. 두 번째 방식을 사용하면 a가 초기화 될 때 _a의 값으로 세팅이 되어버린다. 프로그램에서 _a의 값이 변경되는 일이 생기면 a에는 그 내용이 빠져있게 된다. 차이점을 잘 알고 알맞게 사용하자.
함께 고민을 같이 해준 제이슨과 크루들에게 감사하다.