▩ 목 차 ▩
1. 기본 요소: 함수와 변수
1-1. 클래스 계층 정의
1-1-1. 코틀린 인터페이스
1-1-2. open, final, abstract 변경자: 기본적으로 final
1-1-3. 가시성 변경자: 기본적으로 공개[public]
1-1-4. 내부 클래스와 중첩된 클래스: 기본적으로 중첩 클래스[ 중첩 클래스[자바에선 static nested, 바깥쪽 클래스에 대한 참조 저장 안함] class A, 내부클래스[자바에선 inner class, 바깥쪽 클래스에 대한 참조를 저장함] inner class A ]
1-1-5. 봉인된 클래스: 클래스 계층 정의 시 계층 확장 제한[sealed class]
1-2. 뻔하지 않은 생성자와 프로퍼티를 갖는 클래스 선언
1-2-1. 클래스 초기화: 주 생성자와 초기화 블록[ init, 초기화블록, 프로퍼티와 생성자 파라미터를 구분하는 밑줄(_) ]
1-2-2. 부 생성자: 상위 클래스를 다른 방식으로 초기화[ constructor, super() ]
1-2-3. 인터페이스에 선언된 프로퍼티 구현
1-2-4. 게터와 세터에서 뒷받침하는 필드에 접근[ field, value ]
1-2-5. 접근자의 가시성 변경 [ = 프로퍼티 가시성 ]
1-3. 컴파일러가 생성한 메서드: 데이터 클래스와 클래스 위임
1-3-1. 모든 클래스가 정의해야 하는 메서드[ toString(), equals(), hashCode()
1-3-2. 데이터 클래스: 모든 클래스가 정의해야 하는 메서드 자동 생성[ data class , 깊은 복사 방법 ]
1-3-3. 클래스 위임: by 키워드 사용
1-4. object 키워드: 클래스 선언과 인스턴스 생성
1-4-1. 객체 선언: 싱글턴으로 쉽게 만들기 [ object class ]
1-4-2. 동반 객체: 팩토리 메서드와 정적 멤버가 들어갈 장소 [클래스 내부에 object class(여러개),companion object(한개)]
1-4-3. 동반 객체를 일반 객체처럼 사용 [ 이름 붙이기, 인터페이스 구현, 확장 함수, 가능 ]
1-4-4. 객체 식: 무명 내부 클래스를 다른 방식으로 작성[ object 이용하여 익명 객체 정의, 명시적인 선언없이 객체를 바로 생성 할 수 있는 식 제공, 자바의 익명 클래스와 비슷 ]
2. 정리
■ 1. 기본 요소: 함수와 변수 ■
4장에는 코틀린 클래스를 다루는 방법에 대해 자세히 배우겠다.
코틀린의 클래스와 인터페이스는 자바 클래스, 인터페이스와는약간 다르다. 예를들어, 인터페이스에 프로퍼티 선언이 들어 갈 수 있다.
자바와 달리 코틀린 선언은 기본적으로 finlal이며 public이다. 게다가 중첩 클래스(클래스 내부에 선언한 클래스)는 기본적으로는 내부클래스가 아니다. 즉 코틀린 중첩 클래스에는 외부 클래스에 대한 참조가 없다.
코틀린 컴파일러는 번잡스러움을 피하기 위해 유용한 메서드를 자동으로 만들어준다. 클래스를 data로 선언하면 컴파일러가 일부 표준 메서드를 생성해준다. 그리고 코틀린 언어가 제공하는 위임(delegaation)을 사용하면 위임을 처리하기 위한 준비 메서드를 직접 작성할 필요가 없다.
이번 4장에서는 클래스와 인스턴스를 동시에 선언하면서 만들때 쓰는 Object 키워드에 설명한다. 또한 클래스와 인터페이스에 대해 이야기에 대해 이야기하고 코틀린에서 클래스 계층을 정의할 때 주의해야 할 점을 알아본다.
■ 1-1. 클래스 계층 정의
코틀린에서 클래스 계층을 정의하는 방식과 자바 방식을 비교해보자. 또한 코틀린의 가시성과 접근 변경자에 대해 알아보고, sealed 변경자에 대해 알아보자.
■ 1-1-1. 코틀린 인터페이스
코틀린 인터페이스 안에는 추상 메서드뿐 아니라 구현이 있는 메서드도 정의할 수 있다. 다만, 인터페이스에는 아무런 필드(상태)도 들어갈 수 없다.
[EX] - 간단한 인터페이스 선언하기
package list4
interface Clickable {
fun click()
}
위의 코드는 click라는 추상 메서드가 있는 인터페이스를 정의한다. 이 인터페이스를 구현하는 클래스는 click에 대한 구현을 제공해야 한다.
다음로느는 이 인터페이스를 구현하는 클래스를 알아보자. 아래 예제를 보자.
[EX] - 단순한 인터페이스 구현하기
package list4
import list3.joinToString2
class Button : Clickable {
override fun click() = println("I was clicked")
}
fun main(args : Array<String>){
Button().click()
}
I was clicked
자바에서는 extends와 implements 키워드를 사용하지만, 코틀린에서는 클래스 이름 뒤에 콜론(:)을 붙이고 인터페이스와 클래스 이름을 적는 것으로 클래스 확장과 인터페이스 구현을 모두 처리한다.
자바와 마찬가지로 클래스는 인터페이스를 원하는 만큼 개수 제한 없이 마음대로 구현할 수 있지만, 클래스는 오직 하나만 확장할 수 있다.
자바의 @Override 애노테이션과 비슷한 override 변경자는 상위 클래스나 상위 인터페이스에 있는 프로퍼티나 메서드를 오버라이드 한다는 표시다. 하지만 자바와 달리 코틀린에서는 override 변경자를 꼭 사용해야 한다. [ override 변경자는 실수로 상위 클래스의 메서드를 오버라이드하는 경우를 방지를 해주기 위해서 명시적으로 작성해야한다. ]
인터페이스 메서드도 디폴트 구현을 제공할 수 있다. 코틀린에서는 그냥 메서드 본문을 메서드 시그니처 뒤에 추가하면 된다.
[EX] - 인터페이스 안에 본문이 있는 메서드 정의하기
package list4
interface Clickable2 {
fun click() //일반 메소드 선언
fun showOff() = println("I'm clickable!") //디폴트 구현이 있는 메소드
}
이 인터페이스를 구현하는 클래스는 click에 대한 구현을 제공해야 한다. 반면에, showOff()메서드의 경우 새로운 동작을 정의할 수도 있고, 그냥 정의를 생략해서 디폴트 구현을 사용할 수도 있다.
다음에는 다른 인터페이스가 새롭게 showOff()메소드를 구현을 하는 예제를 해보자.
[EX] - 동일한 메서드를 구현하는 다른 인터페이스 정의
package list4
interface Focusable {
fun setFocus(b: Boolean) =
println("I ${if(b) "got" else "lost" } focus.")
fun showOff() = println("I'm focusable!")
}
한 클래스에서 이 두 인터페이스를 함께 구현하면 어떻게 될 까? 두 인터페이스 모두 디폴트 구현이 들어있는 showOff()메소드 인데 말인데.. 어느 쪽 showOff메서드가 선택 될까?
==> 어느쪽도 선택되지 않는다. 클래스가 구현하는 두 상위 인터페이스에 정의된 showOff()구현을 대체할 오버라이딩 메서드를 직접 제공하지 않으면 컴파일 오류가 발생한다. [ 코틀린 컴파일러는 두 메서드를 아우르는 구현을 하위 클래스에 직접 구현하게 강제 한다. ]
[EX] - 상속한 인터페이스의 메서드 구현 호출하기
package list4
class Button2 : Clickable2, Focusable {
override fun click() = println("I was clicked")
override fun showOff() {
super<Clickable2>.showOff()
super<Focusable>.showOff()
}
}
Button2 클래스는 두 인터페이스를 구현한다. Button2은 상속한 두 상위 타입의 showOff()메서드를 호출하는 방식으로 showOff()를 구현한다. 상위 타입의 구현을 호출할 때에는 자바와 마찬가지로 super를 사용한다. 하지만 구체적으로 타입을 지정하는 문법은 다르다.
자바에서는 Clickable2.super.showOff()처럼 super 앞에 기반 타입을 적지만, 코틀린에서는 super<Clickable>.showOff()처럼 제네릭(꺽쇠<>) 괄호 안에 기반 타입 이름을 지정한다.
상속한 구현 중 단 하나만 호출해도 된다면 다음과 같이 쓸 수도 있다.
override fun showOff() = super<Clickable2>.showOff()
위에 정의한 Button2 클래스 객체를 만들고 사용해보자.
fun main(args : Array<String>){
val Button = Button2()
Button.showOff()
Button.setFocus(true)
Button.click()
}
I'm clickable!
I'm focusable!
I got focus.
I was clicked
위의 코드를 보자.
showOff()메소드로부터 I'm clickable! I'm focusable! 출력되었고, setFocus(true)메소드로부터 I got focus. 출력되었고 click()메소드로부터 I was clicked가 출력되었다.
지금은 코틀린에서 메서드가 정의된 인터페이스를 사용하는 방법을 살펴보았는데, 이제는 기반 클래스에 정의된 메서드를 오버라이드하는 방법을 알아본다.
■ 1-1-2. open, final, abstract 변경자: 기본적으로 final
자바에서는 final로 선언된 클래스는 상속을 금지 시킨다.
==> 기본적으로 상속이 가능하면 편리한 경우도 많지만 문제가 생기는 경우도 많기 때문이다.
예를 들어, 어떤 클래스가 자신을 상속하는 방법에 대한 정확한 규칙[ 어떤 메소드를 어떻게 오버라이드 해야 하는지 등)을 제공하지 않는다면 그 클래스의 클라이언트는 기반 클래스를 작성한 사람의 의도와 다른 방식으로 메서드를 오버라이드 할 위험이 있다.
==> 이것을 방지하기 위해 특별히 하위 클래스에서 오버라이드히게 의도된 클래스와 메서드가 아니라면 모두 final로 만들라고 한다.
[ 자바 프로그래밍 기법에 대한 책 중 가장 유명한 책인 조슈아 블로크가 쓴 "Effective Java"에서.. ]
코틀린도 위와 같은 철학을 따라서 코틀린의 클래스와 메서드는 기본적으로 final이다.
코틀린에서는 어떤 클래스의 상속을 허용하려면 클래스 앞에 open 변경자를 붙여야 한다. 그와 더불어 오버라이드를 허용하고 싶은 메서드나 프로퍼티 앞에도 open 변경자를 붙여야 한다.
[EX] - 열린 메서드를 포함하는 열린 클래스 정의하기
package list4
open class RichButton : Clickable { // 이 클래스는 열려있으므로, 다른 클래스가 이 클래스를 상속할 수 있다.
fun disable() {} // 이 함수는 파이널이므로 하위 클래스가 이 메서드를 오버라이드 할 수 없다.
open fun animate() {} // 이 함수는 열려 있으므로 하위 클래스에서 이 메서드를 오버라이드 해도 된다.
override fun click() {} // 이 함수는 (상위 클래스에서 선언된) 열려있는 메서드를 오버라이드 한다. 오버라이드한 메서드는 기본적으로 열려잇다.(open)
}
기반 클래스나 인터페이스의 멤버를 오버라이드하는 경우 그 메서드는 기본적으로 열려있다. 오버라이드하는 메서드의 구현을 하위 클래스에서 오버라이드 하지 못하게 금지하려면 오버라이드 하는 메서드 앞에 final을 명시해야 한다. 왜냐하면 오버라이드 하는 메서드는 기본적으로 open이기 때문이다.
[EX] - 오버라이드 금지하기
package list4
open class RichButton2 : Clickable { // 이 클래스는 열려있으므로, 다른 클래스가 이 클래스를 상속할 수 있다.
final override fun click() {} // 여기 있는 "final"은 쓸데 없이 붙은 중복이 아니다. "final"이 없는 오버라이드한 메소드나 프로퍼티는 기본적으로 열려있기 때문이다.
}
클래스의 기본적인 상속 가능 상태를 final로 함으로써 스마트 캐스트가 가능하다는 점이다.
스마트 캐스트는 타입 검사뒤에 변경될 수 없는 변수에만 적용이 가능하며, 클래스 프로퍼티의 경우 val이면서 커스텀 접근자가 없는 경우에만 스마트 캐스트를 쓸 수 있다는 의미다. 여기서의 기본 전제조건은 final이어야만 한다는 것이어야만 한다는 뜻이기도 하다. 왜냐하면 프로퍼티가 fina이 아니라면 그 프로퍼티를 다른 클래스가 상속하면서 커스텀 접근자를 정의함으로써 스마트 캐스트의 요구사항을 깰 수 있기 때문이다.
프로퍼티는 기본적으로 final 이기 때문에 따로 고민할 필요 없이 대부분의 프로퍼티를 스마트 캐스트에 활용할 수 있다.
==> 이는 코드를 더 이해하기 쉽게 만든다.
코틀린에서도 클래스를 abstract로 선언할 수 있다. abstract로 선언한 추상 클래스는 인스턴스화할 수 없다.
추상 클래스에는 구현이 없는 추상 멤버가 있기 때문에 하위 클래스에서 그 추상 멤버를 오버라이드해야만 하는게 보통이다.
추상 멤버는 항상 열려있다. 따라서 추상 클래스의 추상 멤버 앞에 open 변경자를 명시할 필요가 없다.
[EX] - 추상 클래스 정의하기
package list4
abstract class Animated { // 이 클래스는 추상 클래스다. 이 클래스의 인스턴스는 만들 수 없다.
abstract fun animate() // 이 함수는 추상 함수다. 이 함수에는 구현이 없다. 한위 클래스에서는 이 함수를 반드시 오버라이드 해야한다.
open fun stopAnimating() { // 추상 클래스에 속해 있더라도 비추상 함수는 기본적으로 파이널이지만 원한다면 open으로 오버라이드를 허용할 수 있다.
}
fun animateTwice(){ // 추상 클래스에 속했더라도 비추상 함수(abstract가 아닌)는 기본적으로 파이널이다.
}
}
인터페이스 멤버의 경우 final, open, abstract를 사용하지 않는다.
==> 인터페이스 멤버는 항상 열려 있으며 final로 변경할 수 없다. 인터페이스 멤버에게 본문이 없으면 자동으로 추상 멤버가 되지만, 그렇더라도 따로 멤버 선언 앞에 abstract 키워드를 덧붙일 필요가 없다. 왜냐하면 기본적으로 자동으로 추상 메서드의 기능을 가지기 때문이다.
< 클래스 내에서 상속 제어 변경자의 의미 >
■ 1-1-3. 가시성 변경자: 기본적으로 공개
가시성 변경자는 코드 기반에 있는 선언에 대한 클래스 외부 접근을 제어한다.
==> 어떤 클래스의 구현에 대한 접근을 제한함으로써 그 클래스에 의존하는 외부 코드를 개지 않고도 클래스 내부 구현을 변경할 수 있다.
기본적으로, 코틀린 가시성 변경자는 자바와 비슷하다. [ 자바와 같은 public, protected, privae 변경자가 있다. ]
하지만 코틀린의 기본 가시성은 자바와 다르다. 아무 변경자도 없는 경우 선언은 모두 공개(public) 된다.
자바의 기본 가시성인 패키지 전용(package-private)는 코틀린에 없다. 코틀린은 패키지를 네임스페이스(namespace)를 관리하기 용도로만 사용하기 때문에 패키지를 가시성에 사용하지 않는다.
==> 패키지 전용 가시성에 대한 대안으로 코틀린에는 internal(모듈 내부)이라는 새로운 가시성 변경자를 도입했다. internal은 "모듈 내부에서만 볼 수 있음"이라는 뜻이다. 모듈은 한번에 한꺼번에 컴파일되는 코틀린 파일들을 의미한다. [ 인텔리J나 이클립스, 메이븐, 그레이들 등의 프로젝트가 모듈이 될 수 있고, 앤트 테스크(task)가 한번 실행될 때 함께 컴파일 되는 파일의 집합도 모듈이 될 수 있다. ]
모듈 내부 가시성은 모듈의 구현에 대해 진정한 캡슐화를 제공한다는 장점이 있다.
자바에서는 패키지가 같은 클래스를 선언하기만 하면 어떤 프로젝트의 외부에 있는 코드라도 패키지 내부에 있는 패키지 전용 선언에 쉽게 접글 할 수 있기 때문에 모듈의 캡슐화가 쉽게 깨진다.
다른 차이는 코틀린에서는 최상위 선언에 대해 private(비공개)을 허용한다는 점이다. 그런 최상위 선언에는 클래스, 함수, 프로퍼티 등이 포함된다. 비공개 가시성의 최상위 선언은 그 선언이 들어있는 파일 내부에서만 사용할 수 있다.(자바와 동일)
==> 이로 인해 하위 시스템의 자세한 구현 사항을 외부에 감추고 싶을 때 유용한 방법이다.
< 코틀린의 가시성 변경자 >
예제를 살펴보며 이해를 해보자.
internal open class TalkativeButton : Focusable {
private fun yell() = println("Hey!")
protected fun whisper() = println("Let's talk!")
}
fun TalkativeButton. giveSpeech() { // 오류: public 멤버가 자신의 internal 수신타입인 TalkativeButton을 노출함
yell() // 오류: yell은 TalkativeButton의 private 멤버라서 yell에 접근 할 수 없음
whisper() // 오류: whisper는 TalkativeButton의 protected 멤버라서 whisper에 접근할 수 없음.
}
위의 코드에서 주석마다 컴파일 오류를 달아놓았다.
코틀린은 public 함수인 giveSpeech 안에서 그보다 가시성이 더 낮은 더 낮은(internal) 타입인 TalkativeButton을 참조하지 못하게 된다.
즉, 어떤 클래스의 확장 함수를 정의하기 위해서는 그 클래스에 대한 가시성의 등급에 따라 내가 함수를 정의할 수 있고, 그 클래스 안에 있는 메소드에 접근하기 위해서는 그 클래스 안에 있는 메소드에 대한 가시성의 등급에 따라 접근할 수 있다.
또한 주의해야 할 점은 내가 사용할 클래스라던지 메소드는 내가 정의할 클래스라던지 메소드보다 높은 등급의 가시성을 사용해야 한다.
가시성 등급 : private < protected < intenal < public / 내가 정의할 클래스 메소드 가시성 < 내가 사용 할 클래스o 메소드 가시성
자바에서는 같은 패키지 안에서 protected 멤버에 접근할 수 있지만, 코틀린에서는 같은 패키지 안이더라도 protected 멤버에 접근 할 수 없다. 즉, 자바와 코틀린의 protected가 다르다는 사실에 유의하자.
코틀린에서의 protected 멤버는 해당하는 클래스나 그 클래스를 상속한 클래스 안에서만 보인다.
==> 클래스를 확장한함수(확장함수)는 그 클래스의 privae이나 protected 멤버에 접근할 수 없다는 사실이다.
코틀린과 자바 가시성 규칙의 또 다른 차이는 코틀린에서는 외부 클래스가 내부 클래스나 중첩된 private 멤버에 접근할 수 없다는 점이다.
■ 1-1-4. 내부 클래스와 중첩된 클래스: 기본적으로 중첩 클래스
[ *중첩 클래스 : 클래스가 여러 클래스와 관계를 맺는 경우에는 독립적으로 선언하는 것이 좋으나, 특정 클래스와 관계를 맺을 경우에는 관계 클래스를 클래스 내부에 선언하는 것이 좋다. 여기서 중첩 클래스란, 클래스 내부에 선언한 클래스를 말한다. ]
- 중첩 클래스를 사용하면 두 클래스의 멤버들을 서로 쉽게 접근할 수 있다.
- 외부에는 불 필요한 관계 클래스를 감춤으로서 코드의 복잡성을 줄일 수 있다.
[ 중첩 클래스에 대해 참고해라 https://bj-turtle.tistory.com/66 ]
자바처럼 코틀린에서도 클래스 안에 다른 클래스를 선언할 수 있다.
==> 클래스 안에 다른 클래스를 선언하면 도우미 클래스를 캡슐화하거나 코드 정의를 그 코드를 사용하는 곳 가까이 두고 싶을 때 유용하다.
자바와의 차이는 코틀린의 중첩 클래스(nested class)는 명시적으로 요청하지 않는 한 바깥쪽 클래스 인스턴스에 대한 접근 권한이 없다는 점이다.
예를들어, View 요소를 하나 만든다고 상상하면, 그 View 상태를 직렬화(객체가 아무리 복잡하더라도, 객체직렬화를 이용하면 객체의 내용을 자바 I/O가 자동으로 바이트 단위로 변환하여 저장이나 전송)해야 한다. 뷰를 직렬화하는 일은 쉽지 않지만, 필요한 모든 데이터를 다른 도우미 클래스로 복사할 수 있다. 이를 위해 State 인터페이스를 선언하고 Serializable을 구현한다. View 인터페이스 안에는 뷰의 상태를 가져와 저장할 때 사용할 getCurrentState와 restoreState메소드 선언이 있다.
[EX] - 직렬화할 수 있는 상태가 있는 뷰 선언
package list4
import java.io.Serializable
interface State: Serializable
interface View {
fun getCurrentState() : State
fun restoreState(state: State){}
}
Button 클래스의 상태를 저장하는 클래스는 Button 클래스 내부에 선언하면 편하다.
우선 자바에서 그런 선언을 어떻게 하는지 살펴보자. [ 추후에 코틀린 코드를 소개함 ]
[EX] - 자바에서 내부 클래스를 사용해 View 구현하기
package list4
public class Button3 implements View{
@override
public State getCurrentState() {
return new ButtonState();
}
@override
public void restoreState(State state) (/*...*/)
public class ButtonState implements State (/*...*/)
}
위의 코드를 보자.
State 인터페이스를 구현한 ButtonState 클래스를 정의해서 Button에 대한 구체적인 정보를 저장한다.
getCurrentState 메서드 안에서는 ButtonState의 새 인스턴스를 만든다. 실제로는 ButtonState안에 필요한 모든 정보를 추가해야한다.
직렬화하려는 변수는 ButtonState 타입의 state였는데 왜 Button을 직렬화할 수 없다는 예외가 발생할까?
==> ButtonState 클래스는 바깥쪽 Button3 클래스에 대한 참조를 묵시적으로 포함하기 때문이다. 그 참조로 인해 ButtonState를 직렬화 할 수 없다. Button3를 직렬화 할 수 없으므로 버튼3에 대한 참조가 ButtonState의 직렬화를 방해한다.
==> 이 문제를 해결하려면 ButtonState를 static 클래스로 선언해야 한다. 자바에서 중첩 클래스를 static으로 선언하면 그 클래스를 둘러싼 바깥쪽 클래스에 대한 묵시적인 참조가 사라진다.
[ https://bj-turtle.tistory.com/66?category=1065928 static nested 클래스 참고해라. ]
이제 다시 코틀린으로 가서 중첩된 클래스가 기본적으로 동작하는 방식을 이해해보자.
[EX] - 중첩 클래스를 사용해 코틀린에서 View 구현하기
package list4
class Button3 : View {
override fun getCurrentState(): State = ButtonState()
override fun restoreState(state: State) {
super.restoreState(state)
}
class ButtonState : State {/*...*/ } // 이 클래스는 자바의 static nested 클래스와 대응한다.
}
코틀린 중첩 클래스에 아무런 변경자가 붙지 않으면 자바 static nested 클래스와 같다.
이를 내부 클래스로 변경해서 바깥쪽 클래스에 대한 참조를 포함하게 만들고 싶다면 inner 변경자를 붙여야 한다.
위의 그림을 보면 알겠지만,
코틀린에서 바깥쪽 클래스의 인스턴스를 가르키는 참조를 표기하는 방법도 자바와 다르다. 내부 클래스 Inner 안에서 바깥쪽 클래스 Outer의 참조에 접근하려면 this@Outer라고 써야한다.
자바와 코틀린의 내부 클래스와 중첩 클래스 간의 차이에 대해 배웠다.
이제는 코틀린 중첩 클래스를 유용하게 사용하는 예를 보자. [ 클래스 계층을 만들되 그 계층에 속한 클래스의 속한 클래스의 수를 제한하고 싶은 경우 중첩 클래스를 쓰면 편리하다. ]
정리하자면,
자바에서 그 클래스를 둘러싼 바깥쪽 클래스의 대한 참조를 허용하지 않기 위해선 static class A 를 이용하고 코틀린에서는 아무런 변경자가 붙지 않으면 된다.(class A)
자바에서 그 클래스를 둘러싼 바깥쪽 클래스의 대한 참조를 허용하기 위해선 class A 를 이용하고 코틀린에서는 inner class A 를 이용하면 된다.
바깥쪽 클래스의 대한 참조를 허용하지 않으면 좋은점은
■ 1-1-5. 봉인된 클래스: 클래스 계층 정의 시 계층 확장 제한(sealed class)
예전 2장의 식을 표현하는 클래스 계층을 생각해보자.
상위 클래스에는 숫자를 표현하는 Num과 덧셈 연산을 표현하는 Sum이라는 두 하위 클래스가 있다. when 식에서 이 모든 하위 클래스를 처리하면 편리하다. 하지만 when 식에서 Num과 Sum이 아닌 경우를 처리하는 else 분기를 반드시 넣어줘야만 한다.
[EX] - 인터페이스 구현을 통해 식 표현하기
package list4
interface Expr
class Num(val value: Int) : Expr
class Sum(val left: Expr, val right: Expr) : Expr
fun eval(e: Expr): Int =
when(e) {
is Num -> e.value
is Sum -> eval(e.right) + eval(e.left)
else ->
throw IllegalArgumentException("Unknown expression")
}
코틀린 컴파일러는 when을 사용해 Expr 타입의 값을 검사할 때 꼭 디폴트 분기인 else 분기를 덧붙이게 강제한다.
이 예제의 else 분기에서는 반환할 만한 의미 있는 값이 없으므로 예외를 던진다.
여기서 디폴트 분기를 추가하는게 편하지는 않다. 그리고 디폴트 분기가 있으면 이런 클래스 계층에 새로운 하위 클래스를 추가하더라도 컴파일러가 when이 모든 경우를 처리하는지 제대로 검사할 수 없다. 그리고 새로운 클래스 처리를 잊저버렸더라도 디폴트 분기가 선택되기 때문에 심각한 버그가 발생할 수 있다.
==> 코틀린은 이런 문제에 대한 해법을 제공한다. sealed 클래스가 그 답이다. 상위 클래스에 sealed 변경자를 붙이면 그 상위 클래스를 상속한 하위 클래스 정의를 제한할 수 있다.
sealed 클래스의 하위 클래스를 정의할 때는 반드시 상위 클래스 안에 중첩시켜야 한다.
package list4
sealed class Expr2 { //기반 클래스를 sealed로 봉인한다.
class Num(val value: Int) : Expr2() //기반 클래스의 모든 하위 클래스를 중첩 클래스로 나열한다.
class Sum(val left: Expr2, val right: Expr2) : Expr2() //기반 클래스의 모든 하위 클래스를 중첩 클래스로 나열한다.
}
fun eval(e: Expr2): Int =
when(e) { //when식이 모든 하위 클래스를 검사하므로 별도의 else 분기가 없어도 된다.
is Expr2.Num -> e.value
is Expr2.Sum -> eval(e.right) + eval(e.left)
}
when 식에서 sealed 클래스의 모든 하위 클래스를 처리한다면 디폴트 분기(else 분기)가 필요없다.
==> 왜냐하면 기반 클래스(Expr2)의 자식 클래스는 누구인지 정해줬기 때문이다. 이 누구인지를 정해주는 역할을 해주는것이 바로 sealed 클래스이다. 그렇기에 주의해야 할 점이 sealed class를 상속을 받게 되면 똑같은 문제점인 누구의 자식인지를 명확히 해주지 않았기 때문에 when에 else를 붙여야만 하기 때문에 sealed class 를 상속 받지 말고 그 자식 클래스를 상속을 받아라!!!!
sealed로 표시된 클래스는 자동으로 open이다. ==> 따라서 별도로 open 변경자를 붙일 필요가 없다.
sealed 클래스는 자기 자신이 추상 클래스이고, 자신을 상속받는 여러 서브 클래스들을 가질 수 있다.
==> enum 클래스와 달리 상속을 지원하기 때문에, 상속을 활용한 풍부한 동작을 구현할 수 있다.
sealed 클래스의 특성
- sealed 클래스의 서브 클래스들은 반드시 같은 파일 내에 선언되어야 함
- 단, sealed 클래스의 서브 클래스를 상속한 클래스들은 같은 파일 내에 없어도 됨
- sealed 클래스는 기본적으로 abstract 클래스임
- sealed 클래스는 private 생성자만 갖게 됨
==> 이러한 특성 때문에 자신을 상속받는 서브 클래스의 종류를 제한 할 수 있다.
[ 쉽게 말해서, 자신의 서브 클래스 또한 같은 파일 내에 선언되어야 하기 때문에 '얘네 말고 내 자식 없어 다른 곳 안가봐도 돼~' 라고 알려주는 거라고 생각하면 편하다. 그렇기에 상속도 물론 자신이 아닌 서브 클래스만 상속이 가능하다 라고 생각하면 편함. 왜냐하면 서브 클래스를 상속한 클래스들은 같은 파일 내에 없어도 되기 때문이다. ]
주의해야 할 점이 sealed class를 상속을 받게 되면 똑같은 문제점인 누구의 자식인지를 명확히 해주지 않았기 때문에 when에 else를 붙여야만 하기 때문에 sealed class 를 상속 받지 말고 그 자식 클래스를 상속을 받아라!!!!
내부적으로 Expr2 클래스는 private 생성자를 가진다. 그 생성자는 클래스 내부에서만 호출 될 수 있다.
sealed 인터페이스를 정의 할 수는 없다.
==> 봉인된 인터페이스를 만들 수 있다면 그 인터페이스를 자바 쪽에서 구현하지 못하게 막을 수 있는 수단이 코틀린 컴파일러에게 없기 때문이다.
[ 어? 내가 seled interface를 생성해보니까 오류가 뜨지 않는다. 알고보니 Kotlin 1.5버전에서 sealed interface가 추가 되었다고 한다. ]
코틀린에서는 클래스를 확장할 때나 인터페이스를 구현할 때 모두 클론(:)을 사용한다. 하지만 하위 클래스 선언을 자세히 살펴보자.
class Num(val value: Int) : Expr2()
여기서 Expr()에 쓰인 괄호에 대해서는 코틀린의 클래스 초기화에 대해 다루는 다음 절에서 설명한다.
■ 1-2. 뻔하지 않은 생성자와 프로퍼티를 갖는 클래스 선언
자바에서는 생성자를 하나 이상 선언할 수 있다. 코틀린도 비슷하지만 한 가지 바뀐 부분이 있다.
코틀린은 주 생성자(클래스를 초기화할 때 주로 사용하는 간략한 생성자로, 클래스 본문 밖에서 정의함)와 부 생성자(클래스 본문 에서 정의함)를 구분한다.
또한 코틀린에서는 초기화 블록을 통해 초기화 로직을 추가할 수 있다.
주 생성자와 초기화 블록을 선언하는 문법을 살펴보고, 나중에 생성자를 여럿 선언하는 방법을 공부한 후 프로퍼티에 대해 좀 더 자세히 알아본다.
■ 1-2-1. 클래스 초기화: 주 생성자와 초기화 블록
보통 클래스의 모든 선언은 중괄호({}) 사이에 들어간다. 하지만 이 클래스 선언에는 중괄호가 없고 괄호 사이에 val 선언만 존재한다.
==> 이렇게 클래스 이름 뒤에 오는 괄호로 둘러싸인 코드를 주 생성자 라고 부른다. 주 생성자는 생성자 파라미터를 지정하고 그 생성자 파라미터에 의해 초기화되는 프로퍼티를 정의하는 두 가지 목적에 쓰인다.
class User constructor(_nickname: String) { //파라미터가 하나만 있는 주 생성자
val nickname: String
init { // 초기화 블록
nickname = _nickname
}
}
위에서의 코드에서 constructor와 init이라는 새로운 키워드를 볼 수 있다.
constructor 키워드는 주 생성자나 부 생성자 정의를 시작할 때 사용한다.
init 키워드는 초기화 블록을 시작한다. 초기화 블록에는 클래스의 객체가 만들어 질 때(인스턴스화 될 때) 실행될 초기화 코드가 들어간다. 초기화 블록은 주 생성자와 함께 사용된다. 주 생성자는 제한적이기 때문에 별도의 코드를 포함할 수 없으므로 초기화 블록이 필요하다.
필요하다면 클래스 안에 여러 초기화 블록을 선언할 수 있다.
생성자 파라미터 _nickname에서 맨 앞의 밑줄(_)은 프로퍼티와 생성자 파라미터를 구분해준다. 자바에서의 방식처럼 this.nickname = nickname 같은 식으로 생성자와 파라미터와 프로퍼티의 이름을 같게 하고 프로퍼티에 this를 써서 모호성을 없애도 된다.
class User(_nickname: String) { //파라미터가 하나뿐인 주 생성자
val nickname = _nickname // 프로퍼티를 주 생성자의 파라미터로 초기화한다.
}
위의 예제는 위에 있는 같은 클래스를 정의하는 여러 방법 중 하나다. 프로퍼티를 초기화하는 식이나 초기화 블록 안에서만 주 생성자의 파라미터를 참조 할 수 있다는 점에 유의해야 한다.
위에 있는 두 예제는 클래스 본문에서 val 키워드를 통해 프로퍼티를 정의했다. 하지만 주 생성자의 파라미터로 프로퍼티를 초기화한다면 그 주 생성자 파라미터 이름 앞에 val을 추가하는 방식으로 프로퍼티 정의와 초기화를 간략히 쓸 수 있다. 아래와 같이 말이다. [ val이든 var이든 상관없이 추가 가능하다.
class User(val nickname: String)
위에 있는 세가지 에제의 User 선언은 모두 같다. 하지만 마지막 예제 선언이 가장 간결하다.
함수 파라미터와 마찬가지로 생성자 파라미터에도 디폴트 값을 정의할 수 있다.
class User(val nickname: String,
val isSubcribed: Boolean = true) //생성자 파라미터에 대한 디폴트값(기본값)을 제공한다.
클래스의 인스턴스를 만들려면 new 키워드 없이 생성자를 직접 호출하면 된다. 아래를 참고하자.
fun main(args: Array<String>) {
val hyun = User("현석") //isSubscribed 파라미터에는 디폴트 값이 쓰인다.
println(hyun.isSubcribed)
val gye = User("계영",false) //모든 인자를 파라미터 선언 순서대로 지정할 수도 있다.
println(gye.isSubcribed)
val hey = User("혜원",isSubcribed = false) // 생성자 인자 중 일부에 대해 이름을 지정할 수 있다.
println(hey.isSubcribed)
}
true
false
false
클래스에 기반 클래스(상위 레벨에 있는 클래스, 상속을 주는 클래스)가 있다면 주 생성자에서 기반 클래스의 생성자를 호출해야 할 필요가 있다.
기반 클래스를 초기화하려면 기반 클래스 이름 뒤에 괄호를 치고 생성자 인자를 넘긴다. 아래를 참고하자.
open class User(val nickname: String) {...}
class TwitterUser(nickname: String) : User(nickname) {...}
클래스를 정의할 때 별도로 생성자를 정의하지 않으면 컴파일러가 자동으로 아무 일도 하지 않는 인자가 없는 디폴트 생성자를 만들어준다
open class Button // 인자가 없는 디폴트 생성자가 만들어진다.
Button의 생성자는 아무 인자도 받지 않지만, Button 클래스를 상속한 하위 클래스는 반드시 Button 클래스의 생성자를 호출해야 한다.
class RadioButton: Button()
이 규칙으로 인해 상속을 받는 클래스의 뒤에는 기반 클래스의 이름 뒤에는 꼭 빈 괄호가 들어간다.(물론 당연히 생성자 인자가 있다면 괄호 안에 인자가 들어간다.)
반면 인터페이스는 생성자가 없기 때문에 어떤 크랠스가 인터페이스를 구현하는 경우 현재 클래스의 이름 뒤에 있는 상위 클래스 인터페이스 이름 뒤에 괄호가 없다.
그렇기 때문에 클래스 정의에 있는 상위 클래스 및 인터페이스 목록에서 이름 뒤에 괄호가 붙었는지 살펴보면 쉽게 기반 클래스(상위 레벨에 있는 클래스,상속을 주는 클래스)와 인터페이스를 구별 할 수 있다.
어떤 클래스를 클래스 외부에서 인스턴스화하지 못하게 막고 싶다면 모든 생성자를 pirvate으로 만들면 된다. 다음과 같이 주 생성자에 private 변경자를 붙일 수 있다.
class Sercretive private constructor() {}
위의 코드에서 Sercretive 클래스 안에는 주 생성자 밖에 없고 그 주 생성자는 비공개 이므로 외부에서 Serective를 인스턴스 화 할 수 없다.
실제로 대부분의 경우 클래스의 생성자는 아주 단순하다. 생성자에 아무 파라미터도 없는 클래스도 많고, 생성자 코드 안에서 인자로 받은 값을 프로퍼티에 설정하기만 하는 생성자도 많다.
==> 그렇기에 코틀린은 간단한 주 생성자 문법을 제공한다. 대부분 이런 간단한 주 생성자 구문만으로도 충분하다. 하지만 코클린은 더 많은 경우를 대비해 필요해 따라 다양한 생성자를 정의할 수 있게 해준다. 알아보자.
■ 1-2-2. 부 생성자: 상위 클래스를 다른 방식으로 초기화
일반적으로 코틀린에서는 생성자가 여럿 있는 경우가 자바보다 훨씬 적다.
자바에서 오버로드한 생성자가 필요한 상황 중 상당수는 코틀린의 디폴트 파라미터 값과 이름을 붙인 인자 문법을 사용해 해결 가능하다.
[ 인자에 대한 디폴트 값을 제공하기 위해 부 생성자를 여럿 만들지 마라, 대신 파라미터의 디폴트 값을 생성자 시그니처에 직접 명시해라. ]
생성자가 여럿 필요한 경우가 가끔 있다. 일반적으로 프레임워크 클래스를 확장해야 하는데 여러 가지 방법으로 인스턴스를 초기화 할 수 있게 다양한 생성자를 지원해야 하는 경우다.
예를들어, 자바에서 선언된 생성자가 2개인 View 클래스가 있다고 하면 그 클래스를 코틀린으로는 다음으로 비슷하게 정의 가능하다.
open class View3{
constructor(ctx: Context){ //부 생성자
//코드
}
constructor(ctx: Context, attr:AttributeSet){ //부 생성자
//코드
}
}
위의 코드를 보면 주 생성자를 선언하지 않고(클래스 이름 뒤에 ()괄호가 없다.) 부 생성자만 2가지 선언한다.
부 생성자는 constructor 키워드로 시작한다. 필요에 따라 얼마든지 부 생성자를 많이 선언해도 된다.
이 클래스를 확장하면서 똑같이 부 생성자를 정의할 수 있다.
class MyButton : View3 {
constructor(ctx: Context)
:super(ctx) {
//..
}
constructor(ctx: Context, attr: AttributeSet)
:super(ctx,attr){
//..
}
}
여기서 두 부 생성자는 super() 키워드를 통해 자신에 대응하는 상위 클래스 생성자를 호출한다. 아래 그림을 참고하자. 그림에서 화살표는 생성자가 상위 클래스 생성자에게 객체 생성을 위임한다는 사실을 표시한다.
자바와 마찬가지로 생성자에서 this()를 통해 클래스 자신의 다른 생성자를 호출할 수 있다.
class MyButton : View3 {
constructor(ctx: Context)
:this(ctx, MY_STYLE) { //이 클래스의 다른 생성자에게 위임한다.
//..
}
constructor(ctx: Context, attr: AttributeSet)
:super(ctx,attr){
//..
}
}
MyButton 클래스의 생성자 중 하나가 파라미터의 디폴트 값을 넘겨서 같은 클래스의 다른 생성자(this를 사용해 참조함)에게 생성을 위임한다. 두번째 생성자는 여전히 super()를 호출한다.
클래스에 주 생성자가 없다면 모든 부 생성자는 반드시 상위 클래스를 초기화하거나 다른 생성자에게 생성을 위임해야 한다.
위의 그림4.4를 바탕으로 생각해보면 각 부 생성자에서 객체 생성을 위임하는 화살표를 따라가면 그 끝에는 결국 상위 클래스 생성자를 호출하는 화살표가 있어야 한다는 뜻이다.
부 생성자가 필요한 주된 이유는 자바 상호운용성이다. 하지만 부 생성자가 필요한 다른 경우도 있다. 클래스 인스턴스를 생성할 때 파라미터 목록이 다른 생성 방법이 여럿 존재하는 경우에는 부 생성자를 여럿 둘 수 밖에 없다.
지금까지 뻔하지 않은 생성자를 정의하는 방법을 살펴보았는데, 이제는 뻔하지 않은 프로퍼티를 살펴보자.
[ 정리하자면, 주생성자 부생성자가 있다.
주 생성자는 클래스이름()을 이용하며 여기서 괄호안에 변수와 변수 타입을 이용하여 생성자를 설정하는 것이고, init 키워드는 초기화 블록을 시작하고, 초기화 블록은 주 생성자와 함께 사용된다.
부 생성자는 constructor 키워드로 시작한다. 필요에 따라 얼마든지 부 생성자를 많이 선언해도 된다.
부 생성자에서 주의해야 할 점은 만약 상속 받을 클래스가 부 생성자를 이용해 생성자를 만들었다면,
': 상속받을클래스이름()'이 아니라 괄호없이 ': 상속받을클래스이름' 을 입력해야한다. ]
■ 1-2-3. 인터페이스에 선언된 프로퍼티 구현
코틀린에서는 인터페이스에 추상 프로퍼티 선언을 넣을 수 있다. 다음은 추상 프로퍼티 선언이 들어있는 인터페이스 선언의 예이다.
interface User2 {
val nickname: String
}
위 코드를 보면 User 인터페이스를 구현하는 클래스가 nickname의 값을 얻을 수 있는 방법을 제공해야 한다는 뜻이다.
인터페이스에 있는 프로퍼티 선언에는 뒷받침하는 필드나 게터 등의 정보가 들어 있지 않다.
==> 사실 인터페이스는 아무 상태도 포함할 수 없으므로 상태를 저장할 필요가 있다면 인터페이스를 구현한 하위 클래스에서 상태 저장을 위한 프로퍼티 등을 만들어야 한다.
아래 예제는 PrivateUser는 별명을 저장하기만 하고 SubscribingUser는 이메일을 함께 저장한다. FacebookUser는 페이스북 계정의 ID를 저장한다. 이 세 클래스는 각각 다른 방식으로 추상 프로퍼티 nickname을 구현한다.
[EX] - 인터페이스의 프로퍼티 구현하기
class PrivateUser(override val nickname: String ) :User2 //주 생성자에 있는 프로퍼티
class SubscribingUser(val email: String) : User2 {
override val nickname: String
get() = email.substringBefore('@') //커스텀 게터
}
class FacebookUser(val accountId: Int) : User2 {
override val nickname = getFacebookName(accountId)
}
fun main(args: Array<String>){
println(PrivateUser("test@kotlinlang.ort").nickname)
println(SubscribingUser("test@kotlinlang.org").nickname)
}
test@kotlinlang.ort
test
privateUser는 주 생성자 안에 프로퍼티를 직접 선언하는 간결한 구문을 사용한다. 이 프로퍼티는 User2의 추상 프로퍼티를 구현하고 있으므로 override를 표시해야 한다.
SubscribingUser는 커스텀 게터로 nickname 프로퍼티를 설정한다. 이 프로퍼티는 뒷받침하는 필드에 값을 설정하지 않고 매번 이메일 주소에서 별명을 계산해 반환한다.
FacebookUser에서는 초기화 식으로 nickname 값을 초기화 한다. 이때 페이스북 사용자 ID를 받아서 그 사용자의 이름을 반환해주는 getFacebook 함수(다른 곳에 정의돼 있다고 가정한다. 위의 코드를 그대로 쳤을때 컴파일 오류가 뜰 것)를 호출해서 nickname을 초기화한다. getFacebookName은 페이스북에 접속해서 인증을 거친 후 원하는 데이터를 가져와야 하기 때문에 비용이 많이 들 수도 있다.
그래서 객체를 초기화하는 단계에 한번만 getFacebookName을 호출하여 설계하였다.
SubscribingUser와 FacebookUser의 nickname 구현 차이에 주의하라. 그 둘은 비슷해 보이지만, SubscribingUser의 nickname은 매번 호출될 때마다 substringBefore를 호출해 계산하는 커스텀 게터를 활용하고, FacebookUser의 nickname은 객체 초기화 시 계산한 데이터를 뒷받침하는 필드에 저장했다가 불러오는 방식을 활용한다.
[ 코틀린에서는 필드를 바로 선언할 수 없고 프로퍼티로 선언하면 자동으로 backing field가 생긴다. ]
인터페이스에는 추상 프로퍼티뿐 아니라 게터와 세터가 있는 프로퍼티를 선언할 수도 있다. 물론 그런 게터와 세터는 뒷받침하는 필드를 참조 할 수 없다. [ 뒷받침하는 필드가 있다면 인터페이스에 상태를 추가하는 셈인데, 인터페이스는 상태를 저장 할 수 없기 때문이다. ]
아래 예제를 보자.
interface User3 {
val email: String
val nickname: String
get() = email.substringBefore('@') //프로퍼티에 뒷받침하는 필드가 없다. 대신 매번 결과를 계산해 돌려준다.
}
class qq(override val email: String) : User3 {
}
fun main(args: Array<String>){
val gg = qq("sfs@aaa.com")
println(gg.nickname)
}
sfs
위의 인터페이스에는 추상 프로퍼티인 email과 커스텀 게터가 있는 nickname 프로퍼티가 함께 들어있다. 하위 클래스는 추상 프로퍼티인 email을 반드시 오버라이드 해야한다. 반면 nickname은 오버라이드 하지 않고 상속할 수 있다.
인터페이스에 선언된 프로퍼티와 달리 클래스에 구현된 프로퍼티는 뒷받침하는 필드를 원하는대로 사용할 수 있다.
이제 접근자에서 뒷받침하는 필드를 가리키는 방법을 살펴보자.
■ 1-2-4. 게터와 세터에서 뒷받침하는 필드에 접근
위에서 프로퍼티의 두가지 유형[ 값을 저장하는 프로퍼티와 커스텀 접근자에서 매번 값을 계산하는 프로퍼티 ] 에 대해 살펴봤다.
이제는 두 유형을 조합해 어떤 값을 저장하되 그 값을 변경하거나 읽을 때마다 정해진 로직을 실행하는 유형의 프로퍼티를 만드는 방법을 살펴보자.
==> 값을 저장하는 동시에 로직을 실행할 수 있게 하기 위해서는 접근자 안에서 프로퍼티를 뒷받침하는 필드에 접근할 수 있어야 한다.
프로퍼티에 저장된 값의 변경 이력을 로그에 남기려는 경우 변경 가능한 프로퍼티를 정의하되 세터에서 프로퍼티 값을 바꿀 때마다 약간의 코드를 추가로 실행해야한다.
package list4
class User4(val name: String){
var address: String = "unspecified"
set(value) {
println(""")
Adrress was changed for $name:
"$field" -> "$value".""".trimIndent()) // 뒷받침하는 필드 값 읽기
field = value // 뒷받침하는 필드 값 변경하기
}
}
fun main(args: Array<String>){
val User = User4("Alice")
User.address = "Elsenheimerstrasse 47, 80687 Muechen"
println(User.address)
}
Adrress was changed for Alice:
"unspecified" -> "Elsenheimerstrasse 47, 80687 Muechen".
Elsenheimerstrasse 47, 80687 Muechen
위의 코드에서 $field의 값은 unspecified으로 address 프로퍼티의 초기값이다. $value의 값은 Elsenheimerstrasse 47, 80687 Muechen 으로 세터를 통해 값을 넘은 것이 출력이 된다. 즉 field = value를 하기전에는 제일 첫 값(초기화값)이 unspecified 나오는게 당연하다.
코틀린에서 프로퍼티의 값을 바꿀때는 user.address = "new value"처럼 필드 설정 구문을 사용한다. 이 구문은 내부적으로 address의 세터를 호출한다. 이 예제에서는 커스텀 세터를 정의해서 추가 로직을 실행한다.(여기선 단순하게 화면에 값의 변화를 출력하기만 함)
접근자의 본문에서는 field라는 특별한 식별자를 통해 뒷받침하는 필드에 접근할 수 있다. 게터에서는 field 값을 읽을 수만 있고, 세터에서는 field값을 읽거나 쓸 수 있다. [ 변경 가능 프로퍼티의 게터와 세터 중 한쪽만 정의해도 된다.]
위의 예제에서는 address의 게터는 필드 값을 그냥 반환해주는 뻔한 게터 이기 때문에 직접 정의할 필요가 없다.
filed를 사용하지 않는 커스텀 접근자 구현을 정의한다면 뒷받침하는 필드는 존재하지 않는다.(즉, backing field를 만들지 않으려고 의도한다면, 프로퍼티가 val인 경우에는 게터에 field가 없으면 되고, var인 경우에는 게터나 세터 모두에 filed가 없어야 한다.)
때로 접근자 기본 구현을 바꿀 필요는 없지만 가시성을 바꿀 필요가 있는 때가 있다. 접근자의 가시성을 어떻게 바꾸는지 알아보자.
■ 1-2-5. 접근자의 가시성 변경
접근자의 가시성은 기본적으로 프로퍼티의 가시성과 같다.
하지만 원한다면 get이나 set 앞에 가시성 변경자를 추가해서 접근자의 가시성을 변경 할 수 있다.
접근자의 가시성을 변경하는 방법을 다음 예제에서 살펴보자.
package list4
class LengthCounter{
var counter: Int = 0
private set // 이 클래스 밖에 이 프로퍼티의 값을 바꿀 수 없다.
fun addWord(word: String){
counter += word.length
}
fun main(args: Array<String>){
val leng = LengthCounter()
leng.counter = 1 // leng.counter의 set(세터)는 private로 선언되었지만 LengthCounter 클래스 내부에 있기 때문에 컴파일 오류가 나지 않는다.
println(leng.counter)
}
}
fun main(args: Array<String>){
val leng = LengthCounter()
// leng.counter = 1 // leng.counter의 set(세터)는 private로 선언되었기 때문에 LengthCounter클래스 내에서만 사용 가능하다. 여기는 LengthCounter의 밖의 main문이라 컴파일 오류가 뜬다.
}
■ 1-3. 컴파일러가 생성한 메서드: 데이터 클래스와 클래스 위임
코틀린 컴파일러가 데이터 클래스에 유용한 메서드를 자동으로 만들어주는 예와 클래스 위임 패턴을 아주 간단하게 쓸 수 있는 예를 살펴보자.
■ 1-3-1. 모든 클래스가 정의해야 하는 메서드
자바와 마찬가지로 코틀린 클래스도 toString, equals, hashCode 등을 오버라이드 할 수 있다.
코틀린은 이런 메서드 구현을 자동으로 생성해 줄 수 있다. 고객 이름과 우편번호를 저장하는 간단한 Client 클래스를 만들어서 예제에 사용하자.
[EX] - Client 클래스의 초기 정의
package list4
class Client(val name: String, val postalCode: Int)
위의 클래스의 인스턴스를 어떻게 문자열 표현할 지 생각해보자.
문자열 표현: toString()
자바처럼 코틀린의 모든 클래스도 인스턴스의 문자열 표현을 얻을 방법을 제공한다. [ 주로 디버깅과 로깅 시 이 메서드를 사용한다. ]
기본 제공되는 객체의 문자열 표현은 Client@539f23b4 같은 방식인데, 이는 그다지 유용하지 않다.
==> 위의 기본 구현을 바꾸려면 toString 메서드를 오버라이드 해야한다.
[EX] - Client에 toString() 구현하기
package list4
class Client2(val name: String, val postalCode: Int) {
override fun toString() = "Client(name=$name, postalcode=$postalCode)"
}
fun main(args: Array<String>){
val client = Client2("오현석", 4122)
println(client)
}
Client(name=오현석, postalcode=4122)
위의 코드를 보자.
이런 문자열 표현으로부터 기본 문자열 표현보다 더 많은 정보를 얻을 수 있다.
객체의 동등성: equals()
Client 클래스를 사용하는 모든 계산은 클래스 밖에서 이뤄진다. Client는 단지 데이터를 저장할 뿐이며, 그에 따라 구조도 단순하고 내부 정보를 투명하게 외부에 노출하게 설계됐다.
서로 다른 두 객체가 내부에 동일한 데이터를 포함하는 경우 그 둘을 동등한 객체로 간주해야 할 경우 어떻게 할까?
fun main(args: Array<String>){
val client = Client2("오현석", 4122)
val client2 = Client2("오현석", 4122)
println(client==client2)
}
false
위의 코드를 보자. 두 객체는 동일하지 않다. 간단하게 메모리 관점에서 말을 한다면 객체끼리 "==",equals() 비교를 하게 되면 레퍼런스 변수(객체를 가르키는 변수)가 누구를 가르키냐(참조하는 것)가 동일하는 것을 비교하기 때문에 새로 생성한 객체끼리의 비교를 하면 항상 false 가 나온다. [ 아래 링크는 기본자료형과 참조자료형에서의 "==", equals() 비교에 대해 잘 설명해놨으니 참고해라. ]
https://bj-turtle.tistory.com/70
이제 equals()를 추가한 Client 클래스를 살펴보자.
[EX] - Client에 equals() 구현하기
package list4
class Client3(val name: String, val postalCode: Int) {
override fun equals(other: Any?): Boolean { //"Any"는 java.lang.Object에 대응하는 클래스로, 코틀린의 모든 클래스의 최상위 클래스다. "Any?"는 널이 될 수 있는 타입이므로 "other"는 null일 수 있다.
if(other == null || other !is Client3) // "other"가 Client인지 검사한다.
return false
return name == other.name && // 두 객체의 프로퍼티 값이
postalCode == other.postalCode
}
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}
fun main(args: Array<String>){
val client = Client3("오현석", 4122)
val client2 = Client3("오현석", 4122)
println(client==client2)
}
위의 코드를 보자.
코틀린의 is 검사는 자바의 instanceof와 같다. is는 어떤 값의 타입을 검사한다. !is는 is 연산자의 결과를 부정한 값이다.
코틀린에서는 override 변경자가 필수여서 실수로 override fun equals(other:Any?) 대신 override fun equals(other: Client3)를 작성할 수 없다.
==> 그래서 equals를 오버라이드하고 나면 프로퍼티의 값이 모두 같은 두 고객 객체는 동등하리라 예상할 수 있다.
실제로 위의 코드 client1 == client2는 true를 반환한다.
하지만 Client 클래스로 더 복잡한 작업을 수행해보면 제대로 작동하지 않는 경우가 있는데, 면접에서 왜 이 Client가 제대로 작동하지 않는 경우를 말하고 문제가 무엇인지 설명하는 질문이 단골 질문이다.
==> hashCode 정의를 빠뜨려서 그렇다고 답하는 개발자가 많을 것이고, 실제 hashCode가 없다는 점이 원인이다.
해시 컨테이너: hashCode()
자바에서는 equals()를 오버라이드 할 때 반드시 hahCode도 함께 오버라이드 해야 한다. [ https://bj-turtle.tistory.com/61 참고해라 ]
원소가 '오현석'이라는 고객 하나뿐인 집합을 만들자. 그 후 새로 원래의 '오현석'과 똑같은 프로퍼티를 포함하는 새로운 Client 인스턴스를 만들어서 그 인스턴스가 집합안에 들어있는지 검사해보자.
fun main(args: Array<String>){
val processed = hashSetOf(Client3("오현석",4122))
println(processed.contains(Client3("오현석",4122)))
}
false
==> 프로퍼티가 모두 일치하므로 새 인스턴스와 집합에 있는 기존 인스턴스는 동등하다. 따라서 새 인스턴스가 집합에 속했는지 여부를 검사하면 true가 반환되리라 예상할 수 있지만 실제로는 false가 나온다. 이는 메모리관점에서 생각해보면 먼저 heap 영역에 Client3("오현석",4122)의 공간이 할당 되고 stack영역에서 이 heap영역을 가르키는 참조 변수는 processed다. 그리고 processed에 속해있는지 여부를 확인하기 위해 contains()메소드를 이용하여 새롭게 Client3("오현석",4122)의 객체를 생성하여 포함 여부를 확인한다. 당연히 포함이 안되있다. 왜냐하면 새롭게 생성된 Client3("오현석",4122)의 객체는 heap영역에서 새로운 공간을 할당하여 heap 영역내의 서로 다른 영역에 있기 때문에 서로 같지가 않다.(실제 메모리 주소가 다르다)
이것을 해결하기 위해서는 hashCode 메서드를 정의해야 한다. [ hashCode() : 객체의 메모리 주소를 가지고 있음 ]
JVM언어에서는 hashCode가 지켜야 하는 "equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode()를 반환해야 한다" 라는 제약이 있는데 Client는 이를 어기고 있다.
processed 집합은 HashSet이다. HashSet은 원소를 비교할 때 비용을 줄이기 위해 먼저 객체의 해시 코드를 비교하고, 해시 코드가 같은 경우에만 실제 값을 비교한다.
==> 즉, 위의 예제에서 두 Client 인스턴스는 해시코드가 다르기 때문에 두번째 인스턴스가 집합 안에 들어있지 않다고 판단한다. 해시 코드가 다를 때 equasl가 반환하는 값은 판단 결과에 영향을 끼치지 못한다. 즉, 원소 객체들이 해시 코드에 대한 규칙을 지키지 않는 경우 HashSet은 제대로 작동할 수 없다.
위의 문제를 고치기 위해 Client가 hashCode를 구현해야 한다.
[EX] - Client에 hashCode 구현하기
class Client3(val name: String, val postalCode: Int) {
override fun equals(other: Any?): Boolean { //"Any"는 java.lang.Object에 대응하는 클래스로, 코틀린의 모든 클래스의 최상위 클래스다. "Any?"는 널이 될 수 있는 타입이므로 "other"는 null일 수 있다.
if(other == null || other !is Client3) // "other"가 Client인지 검사한다.
return false
return name == other.name && // 두 객체의 프로퍼티 값이
postalCode == other.postalCode
}
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
override fun hashCode(): Int =
name.hashCode() * 31 + postalCode
}
fun main(args: Array<String>) {
val processed = hashSetOf(Client3("오현석", 4122))
println(processed.contains(Client3("오현석", 4122)))
println(processed.hashCode()) // postalCode의 값이 4122이면 hashCode()의 값은 1565306621
print(Client3("오현석", 4122).hashCode()) // postalCode의 값이 4122이면 hashCode()의 값은 1565306621
print(Client3("오현석", 4124).hashCode()) //postalCode의 값이 4124이면 hashCode()의 값은 1565306623
}
true
1565306621
1565306621
1565306623
위의 클래스는 예상한대로 작동한다. 정상적으로 값만으로 비교하는 이유는 hashCode()를 참조하여 메모리 주소 값을 비교하는 equals()를 값만을 비교를 위한 equals()메소드로 재정의를 했고, 메모리 주소 값을 출력하는 hashCode()메소드를 메모리 주소 값이 아니라 데이터의 값에 따른 hashCode()를 재정의 했기 때문이다.
위 코드를 보면 얼마나 많은 코드를 작성해야 했는지 생각해봐라
==> 다행히 코틀린 컴파일러는 이 모든 메서드를 자동으로 생성해 줄 수 있다. 어떻게 하면 코틀린이 이런 메서드를 생성하게 만들 수 있는지 살펴보자.
■ 1-3-2. 데이터 클래스: 모든 클래스가 정의해야 하는 메서드 자동 생성
어떤 클래스가 데이터를 저장하는 역할만을 수행한다면 toString, equals, hashCode를 반드시 오버라이드해야 한다.
==> 인텔리J 아이디어 등의 IDE는 자동으로 그런 메서드를 정의해주고, 작성된 메서드의 정확성과 일관성을 검사해준다.
헉! 코틀린 더 편하다.. 이런 메서드를 IDE를 통해 생성할 필요도 없다. data라는 변경자를 클래스 앞에 붙이면 필요한 메서드를 컴파일러가 자동으로 만들어준다. data 변경자가 붙은 클래스를 데이터 클래스라고 부른다.
[EX] - Client 데이터 클래스로 선언하기
package list4
data class Client4(val name: String, val postalCode: Int)
위의 data Clint4 클래스는 자바에서 요구하는 모든 메서드를 포함한다.
- 인스턴스 간 비교를 위한 equals
- HashMap과 같은 해시 기반 컨테이너에서 키로 사용할 수 있는 hashCode
- 클래스의 각 필드를 순서대로 표시하는 문자열 표현을 만들어주는 toString
equals와 hashCode는 주 생성자에 나열된 모든 프로퍼티를 고려해 만들어진다. 생성된 equals 메서드는 모든 프로퍼티 값의 동등성을 확인한다. hashCode 메서드는 모든 프로퍼티의 해시 값을 바탕으로 계사한 해시 값을 반환한다. 이때 주 생성자 밖에 정의된 프로퍼티는 equals나 hashCode를 계산할 때 고려의 대상이 아니라는 사실에 유의하라
데이터 클래스와 불변성: copy() 메서드
데이터 클래스의 프로퍼티가 꼭 val일 필요는 없다. 원한다면 var 프로퍼티를 써도 된다. 하지만 데이터 클래스의 모든 프로퍼티를 읽기 전용으로 만들어서 데이터 클래스를 불변(immutable)클래스로 만들라고 권장한다.
HashMap 등의 컨테이너에 데이터 클래스 객체를 담는 경우엔 불변성이 필수적이다. 데이터 클래스 객체를 키로 하는 값을 컨테이너에 담은 다음에 키로 쓰인 데이터 객체의 프로퍼티를 변경하면 컨테이너 상태가 잘못 될 수 있다. 게다가 불변 객체를 사용하면 프로그램에 대해 훨씬 쉽게 추론 할 수 있다. 특히 다중스레드 프로그램의 경우 이러한 성질은 더욱 중요하다.
==> 불변 객체를 주로 사용하는 프로그램에서는 스레드가 사용중인 데이터를 다른 스레드가 변경할 수 없으므로 스레드를 동기화해야 할 필요가 줄어든다.
데이터 클래스 인스턴스를 불변 객체로 더 쉽게 활용할 수 있게 코틀린 컴파일러는 한가지 편의 메서드를 제공한다.
==> 객체를 복사하면서 일부 프로퍼티를 바꿀 수 있게 해주는 copy메서드이다. 객체를 메모리상(heap영역)에서 직접 바꾸는 대신 복사본을 만드는 편이 더 낫다. 복사본은 원본과 다른 생명주기를 가지며, 복사를 하면서 일부 프로퍼티 값을 바꾸거나 복사본을 제거해도 프로그램에서 원본을 참조하는 다른 부분에 전혀 영향을 끼치지 않는다. 아래 예제를 살펴보자.
package list4
class Client4(val name: String, val postalCode: Int){
fun copy(name: String = this.name, postalCode: Int = this.postalCode) = Client4(name, postalCode)
override fun toString(): String = "Client(name=$name, postalcode=$postalCode)"
}
fun main(args: Array<String>){
val lee = Client4("이계영",4122)
println(lee.copy(postalCode = 4000))
}
copy() 메소드에 조금 자세히 알아보자.
copy()의 메소드의 문제점은,
문제점 1. data class의 copy는 주생성자의 프로퍼티만 복사한다.
data class User(val name: String, val age: Int) {
var isSuperStar : Boolean = false
}
fun main() {
val user1 = User("minsu", 10)
user1.isSuperStar = true
val user2 = user1.copy()
println(user1.isSuperStar) // true
println(user2.isSuperStar) // false
}
위와 같이 data class에 isSuperStar라는 프로퍼티 값을 지정하고 copy를 해도 user2의 isSuperStar의 기본값이 false로 복사된다. 따라서 copy는 주생성자만 복사함을 알고 copy 메서드를 커스텀하는 등의 작업을 따로 해주어야한다.
문제점 2. 얕은 복사, 깊은 복사
우선 얕은 복사, 깊은 복사에 대해 설명을 하겠다.
얕은 복사 : 객체를 복사할 때, 객체의 주솟값을 복사하는 방법
[EX] - 얕은 복사
val instance1 = SampleClass(1) // 인스턴스 생성
val instance2 = instance1 // 인스턴스1을 2에 얕은 복사 진행
instance1.id = 3 // 얕은 복사를 진행한 객체의 멤버변수를 변경
println(instance2.id) // 3
==> instance2에 instance1을 복사하고 instance1의 값만 변경했는데, instance2의 값이 1에 적용한 값으로 변경되었다.
얕은 복사를 통해 주솟값이 복사되었기 때문에 두 변수가 같은 주솟값을 참조하면서 생기는 문제인 것이다.
깊은 복사 : 객체를 복사할 때, 객체의 주솟값이 아닌 전체 값이 복사되는 방법
[EX] - 깊은 복사
val instance1 = SampleClass(1) // 인스턴스 생성
val instance2 = instance1.copy() // 인스턴스1을 2에 깊은 복사 진행
instance1.id = 3 // 깊은 복사를 진행한 객체의 멤버변수를 변경
println(instance2.id) // 1
==> 얕은 복사와 달리 복사한 객체의 값이 변하지 않는 것을 확인할 수 있다.
data class에서 제공하는 몇가지 기본 오버라이딩 메서드 중 copy() 메서드를 통해서 깊은 복사를 할 수 있다.
[ 물론, 기본형만 가능하며 기본형이 아닌 변수는 깊은 복사가 안된다. 그러므로 재정의 해줘야 한다. ]
다시 copy의 문제점으로 돌아가서..
data class의 copy는 부분적으로 deep copy하지 않기 때문에 아래와 같은 상황이 발생할 수 있다. [ 기본형은 깊은 복사이고, 참조자료형은 얕은복사이다. ]
[EX] - copy()의 기본자료형은 깊은 복사다.
val instance1 = SampleClass(1) // 인스턴스 생성
val instance2 = instance1.copy() // 인스턴스1을 2에 깊은 복사 진행
instance1.id = 3 // 깊은 복사를 진행한 객체의 멤버변수를 변경
println(instance2.id) // 1
위와 같이 기본자료형에 대해서는 깊은 복사를 하여 각각의 값을 바꾸면 같이 변경되지 않고 각각 변경이 된다.
하지만 참조자료형인 경우,
[EX] - copy()의 참조자료형은 얕은 복사다.
data class User(val name: String, val age: Int, var friends: MutableList<String>) {
var isSuperStar : Boolean = false
}
fun main() {
val friends = mutableListOf("a1", "a2", "a3")
val user1 = User("minsu", 10, friends)
user1.isSuperStar = true
val user2 = user1.copy()
user2.friends.add("a4")
println(user1) // User(name=minsu, age=10, friends=[a1, a2, a3, a4])
println(user2) // User(name=minsu, age=10, friends=[a1, a2, a3, a4])
}
위와 같이 user2에 user1을 copy하고 user2의 friends에 a4를 추가했는데 user1의 friends 목록까지 변경되었다. 얕은 복사를 하기 때문에 생기는 문제로, 이러한 경우에는 copy 메서드를 직접 구현해주어야한다. 직접 copy 메서드의 문제점(data class의 주 생성자의 프로퍼티만을 복사, 얕은복사)를 위해 직접 구현하자.
data class User(val name: String, val age: Int, var friends: MutableList<String>) {
var isSuperStar : Boolean = false
fun copy(): User {
val copyUser = User(name, age, friends.toMutableList())
copyUser.isSuperStar = isSuperStar
return copyUser
}
}
fun main() {
val friends = mutableListOf("a1", "a2", "a3")
val user1 = User("minsu", 10, friends)
user1.isSuperStar = true
val user2 = user1.copy()
user2.friends.add("a4")
println(user1) // User(name=minsu, age=10, friends=[a1, a2, a3])
println(user2) // User(name=minsu, age=10, friends=[a1, a2, a3, a4])
println(user1.isSuperStar) // true
println(user2.isSuperStar) // true
}
[ 참고로 copy의 경우 Any의 메서드가 아니기 때문에 override 함수가 아니라 직접 일반 함수로 구현해주는 형태다. ]
그러면 논외로 깊은복사를 하는 방법을 알아보자.
첫번째 방법은 위에 있는 것처럼 copy()메소드를 직접 일반 함수로 구현해주는 것이다.
두번째 방법은 Cloneable 인터페이스를 상속받아 clone()메서드를 오버라이딩을 하는데, 여기서도 기본 자료형은 깊은복사가 되지만, 참조 자료형은 깊은 복사가 되지 않아, clone 메서드를 재정의 해줘야한다.
세번째 방법은 Gson을 활용하는 방식이다. Gson은 자바 오브젝트를 JSON으로 변환시켜주거나 JSON을 자바 오브젝트로 변환해주는 구글에서 만든 라이브러리다. [ 이 라이브러리를 사용하기 위해서는 Gradle 혹은 Maven에서 의존성을 추가해줘야 한다.]
Gson 라이브러리에서 사용할 메소드는 2가지이다.
- fromJson : Json 데이터를 지정한 타입으로 변환
- toJson : 지정한 타입의 데이터를 Json 형식으로 변환
==> toJson 메서드를 사용해서 data class의 값을 먼저 Json 형식으로 변환하고, fromJson 메서드를 사용해서 해당 데이터를 다시 data class의 형식으로 변환해주면 깊은 복사를 쉽게 할 수 있다.
data class SampleClass(var id: Int, var list: MutableList<Int>, var name: String){
// fromJson : Json 데이터를 지정한 타입으로 변환
// toJson : 지정한 타입의 데이터를 Json 형식으로 변환
//toJson 메서드를 사용해서 data class의 값을 먼저 Json 형식으로 변환하고, fromJson 메서드를 사용해서 해당 데이터를 다시 data class의 형식으로 변환해주면 깊은 복사를 쉽게 할 수 있다.
fun deepCopy() : SampleClass {
return Gson().fromJson(Gson().toJson(this), this::class.java)
}
}
fun main(args: Array<String>){
val instance1 = SampleClass(1, mutableListOf(1, 2, 3), "빈지노")
val instance2 = instance1.deepCopy()
instance1.id = 3
instance1.list.add(4)
instance1.name = "창모"
println(instance2.id) // 깊은 복사라면 1 나와야함
println(instance2.list) // 깊은 복사라면 [1, 2, 3] 나와야함
println(instance2.name) // 깊은 복사라면 "빈지노" 나와야함
}
1
[1, 2, 3]
빈지노
지금까지 data 변경자를 통해 값 객체를 더 편리하게 사용하는 방법 및 깊은 복사를 하는 세 가지 방법에 대해 알아보았고,
이제는 IDE가 생성해주는 코드를 사용하지 않고도 위임을 쉽게 사용할 수 있게 해주는 코틀린 기능인 클래스 위임(class delegation)에 대해 살펴보자.
■ 1-3-3. 클래스 위임: by 키워드 사용
대규모 객체지향 시스템을 설계할 때 시스템을 취약하게 만드는 문제는 보통 구현 상속(implementation inheritance)에 의해 발생한다.
하위 클래스가 상위 클래스의 메소드 중 일부를 오버라이드 하면 하위 클래스는 상위 클래스의 세부 구현 사항의 의존하게 된다. 시스템이 변함에 따라 상위 클래스의 구현이 바뀌거나 상위 클래스에 새로운 메서드가 추가된다. 그 과정에서 하위 클래스가 상위 클래스에 대해 갖고 있던 가정이 깨져서 코드가 정상적으로 작동하지 못하는 경우가 생길 수 있다.
==> 코틀린을 설계하면서 우리는 이런 문제를 인식하고 기본적으로 클래스를 final로 취급하기로 결정했다. 모든 클래스를 기본적으로 final로 취급하면 상속을 염두에 두고 open 변경자로 열어둔 클래스만 확장할 수 있다. 열린 상위 클래스의 소스코드를 변경할 때는 open 변경자를 보고 해당 클래스를 다른 클래스가 상속하리라 예상할 수 있으므로 변경 시 하위 클래스를 깨지 않기 위해 좀 더 조심할 수 있다.
하지만 종종 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있다.
==> 이럴 때 사용하는 일반적인 방법이 데코레이션(Decorator)패턴이다. 이 패턴의 핵심은 상속을 허용하지 않는 클래스 대신 사용할 수 있는 새로운 클래스(데코레이터)를 만들되 기존 클래스와 같은 인터페이스를 데코레이터가 제공하게 만들고, 기존 클래스를 데이레이터 내부에 필드로 유지하는 것이다. 이때 새로 정의 해야하는 기능은 데코레이터의 메서드에 새로 정의하고(기존 메서드나 필드를 활용 할 수 있다) 기존 기능이 그대로 필요한 부분은 데코레이터의 메서드가 기존 클래스의 메서드에게 요청을 전달한다.
==> 이런 데코레이션 패턴의 단점은 준비 코드가 상당히 많이 필요하다는 점이다. 예를 들어 Collection 같이 비교적 단순한 인터페이스를 구현하면서 아무 동작도 변경하지 않는 데코레이터를 만들 때 조차도 다음과 같이 복잡한 코드를 작성해야 한다.
class DelegatingCollection<T> : Collection<T> {
private val innerList = arrayListOf<T>()
override val size: Int
get() = innerList.size
override fun isEmpty(): Boolean = innerList.isEmpty()
override fun contains(element: T): Boolean = innerList.contains(element)
override fun iterator(): Iterator<T> = innerList.iterator()
override fun containsAll(elements: Collection<T>): Boolean = innerList.containsAll(elements)
}
이런 위임을 언어가 제공하는 일급 시민 기능으로 지원한다는 점이 코틀린의 장점이다.
인터페이스를 구현할 때 by 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시 할 수 있다.
다음은 위에 예제를 위임을 사용해 재작성한 코드다.
class DelegatingCollection2<T> (
innerList: Collection<T> = ArrayList<T>()
) : Collection<T> by innerList{}
클래스 안에 있던 모든 메서드 정의가 없어졌다. 컴파일러가 그런 전달 메서드를 자동으로 생성하며 자동 생성한 코드의 구현은 DelegatingCollection에 있던 구현과 비슷하다.
메서드 중 일부의 동작을 변경하고 싶은 경우 메서드를 오버라이드하면 컴파일러가 생성한 메서드 대신 오버라이드 한 메서드가 쓰인다.
기존 클래스의 메서드에 위임하는 기본 구현으로 충분한 메서드는 따로 오버라이드 할 필요가 없다.
위의 기법을 이용해서 원소를 추가하려고 시도한 횟수를 기록하는 컬렉션을 구현해보자. 예를 들어 중복을 제거하는 프로세스를 설계하는 중이라면 원소 추가 횟수를 기록하는 컬렉션을 통해 최종 컬렉션 크기와 원소 추가 시도 횟수 사이의 비율을 살펴봄으로써 중복 제거 프로세서의 효율성을 판단할 수 있다.
[EX] - 클래스 위임 사용하기
class CountingSet<T> (
val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet{ //MultableCollection의 구현을 innerSet에게 위임한다.
var objectAdded = 0
override fun add(element: T): Boolean { //이 두 메서드는 위임하지 않고 새로운 구현을 제공한다.
objectAdded++
return innerSet.add(element)
}
override fun addAll(elements: Collection<T>): Boolean { //이 두 메서드는 위임하지 않고 새로운 구현을 제공한다.
objectAdded +=elements.size
return innerSet.addAll(elements)
}
}
fun main(args: Array<String>){
val cset = CountingSet<Int>()
cset.addAll(listOf(1,2,2))
println("${cset.objectAdded} objects were added, ${cset.size} remain")
}
3 objects were added, 2 remain
- by innerSet는 주생성자이고 HashSet이라고가정한다.
- HashSet 클래스는 Collection을 구현했기 때문에 by innerSet으로 선언하면 컴파일러가 자동으로 컴파일 시점에 HashSet 클래스의 모든 메서드를 자동으로 연결해준다. [ 알아야 할 사항이, override된 함수인 경우 override 된 함수를 사용하도록 컴파일러가 따로 무언가를 해주진 않는다. ]
위의 예제를 보면 알 수 있지만 add와 addAll을 오버라이드해서 카운터를 증가시키고, MutableCollection 인터페이스의 나머지 메서드는 내부 컨테이너(innerSet)에게 위임한다.
이때 CountingSet에 MutableCollection의 구현 방식에 대한 의존관계가 생기지 않는다는 점이 중요하다.
예를들어 내부 컨테이너가 addAll을 처리할 때 루프를 돌면서 add를 호출할 수도 있지만, 최적화를 위해 다른 방식을 택할 수도 있다. 클라이언트 코드가 CountinSet의 코드를 호출할 때 발생하는 일은 CountingSet안에서 마음대로 제어할 수 있지만, CoutingSet 코드는 위임 대상 내부 클래스인 MutableCollection에 문서화된 API를 활용한다. 그러므로 내부 클래스(MutableCollection)이 문서화된 API를 변경하지 않는 한 CountingSet 코드가 계속 잘 작동 될 것임을 확신 할 수 있다.
쉽게 말해서 by 클래스의 위임을 사용을 함으로써 상속하지 않고 by 뒤에 적힌 위임 클래스로부터 기존 기능을 그대로 사용(모든 것을 복사해) 내가 원하는 부분은 오버라이딩 하여 새롭게 나만의 object를 정의해서 쓸 수 있는 편리성이 있고, 위임 클래스를 변경하지 않는한 계속 잘 작동 될 것이다.
+) by(위임)에 대해 이해가 안되서 구글에 구글링을 해보았다. 그러면서 쉬운 예제를 통해 by를 이해하게 되었다. 한번 봐보자.
우선적으로 알아야 할 배경지식은 delegate pattern인데, 이것은 내가 할 일을 다른 객체에게 위임하는 패턴이다.
[EX] - by 쉽게 이해하는 예제
interface Speaker {
val subject: String
val script: String
fun say()
}
class DroidKnights : Speaker {
override val subject = "Jetpack Compose 내부 작동 원리"
override val script = """
안녕하세요, 저는 $subject 에 대해 발표할 지성빈 입니다.
Jetpack Compose 는 작년 7월달에 stable 로 출시되었습니다.
...
""".trimIndent()
override fun say() {
println("[$subject] $script")
}
}
//class Sungbin(private val presentation: Speaker) : Speaker {
// override val subject = presentation.subject
// override val script = presentation.script
// override fun say() {
// presentation.say()
// }
//}
class SungbinBy(presentation: Speaker) : Speaker by presentation{
}
fun main(args: Array<String>){
val DroidKnights = DroidKnights()
// val Sungbin = Sungbin(DroidKnights)
val SungbinBy = SungbinBy(DroidKnights)
}
위의 코드를 설명해보겠다.
우선 Speaker라는 인터페이스를 정의했다. 그 후 Speaker 인터페이스를 구현한 DroidKnights 클래스와 생성자로부터 Speaker 인터페이스를 구현한 타입을 생성자로 받는 Sungbin(위 코드에서 주석 처리함) 클래스를 작성하였다.
여기서 자세하게 설명하면, Sungbin 클래스를 보게 되면 DriodKnights 클래스처럼 직접 Speaker 인터페이스를 구현하는게 아니라, Speaker 인터페이스를 구현하고 있는 Speaker 타입 클래스를 생성자를 받아 구현을 해주고 있다. 즉, 직접 Speaker 인터페이스를 구현하는게 아닌 것이다.
==> 이렇게 내가 직접 인터페이스를 구현하는게 아닌 다른 객체에게 내가 할 일을 위임하는것이 delegate pattern이고, 이런 클래스를 class delegate라고 한다. 이러한 delegate 클래스는 많은 코드를 작성해줘야지만 컴파일 오류가 나지 않고 재기능을 할 수 있다.
==> delegate클래스 코드를 위한 많은 코드를 작성을 해줘야하는 불편함을 해소시킨 것이 by 인 것이다!!
by로 작성한 SunbinBy 클래스를 보게 되면 한 줄로 delegate 클래스를 정의할 수 있다. 물론 SunbinBy 클래스의 프로퍼티의 게터 세터를 사용할 수 있다. presentation 프로퍼티는 위임을 받기 때문에 main문에서 SunbinBy.subject = "원하는 값" 구문을 넣어주면 세터와 같이 값이 들어가게 되고, 만약 아무런 값을 넣지 않으면 내가 만들어서 넣었던 객체(Speaker 인터페이스를 직접 구현한 DroidKnights 클래스)의 값들을 물려받아 그 값들이 출력되게 된다. 그리고 SunbinBy.subject을 출력하게 되면 게터와 같은 기능을 할 수 있게 되는데 세터로 아무런 값을 지정하지 않으면 내가 만들어서 넣었던 객체(Speaker 인터페이스를 직접 구현한 DroidKnights 클래스)의 값들을 물려받아 그 값들이 출력 되고 세터를 이용해서 값을 넣게되면 넣은 값이 출력된다.
■ 1-4. object 키워드: 클래스 선언과 인스턴스 생성
코틀린에서는 object 키워드를 다양한 상황에서 사용하지만 모든 경우 클래스를 정의하면서 동시에 인스턴스(객체)를 생성한다는 공통점이 있다.
objcet 키워드를 사용하는 여러 상황을 살펴보자.
- 객체 선언(object declartion)은 싱글턴을 정의하는 방법 중 하나다.
- 동반 객체(companion object)는 인스턴스 메서드는 아니지만 어떤 클래스와 관련 있는 메서드와 팩토리 메서드를 담을 때 쓰인다. 동반 객체 메서드에 접근할 때는 동반 객체가 포함된 클래스의 이름을 사용할 수 있다.
- 객체 식은 자바의 무명 내부 클레스(anonymous inner class) 대신 쓰인다.
■ 1-4-1. 객체 선언: 싱글턴으로 쉽게 만들기
[ *싱글턴 패턴 : 개발을 하다보면 객체에 대한 하나의 인스턴스만 필요할 때, 하나의 인스턴스를 재사용 하기 위해 싱글턴 패턴을 구현해야 할 이이 생긴다. 즉, 싱글턴 패턴은 객체의 인스턴스를 1개만 생성하여 계속 재사용 하는 패턴이다. ]
객체지향 시스템을 설계하다 보면 인스턴스가 하나만 필요한 클래스가 유용한 경우가 많다.
자바에서는 보통 클래스의 생성자를 private으로 제한하고 정적인 필드에 그 클래스의 유일한 객체를 저장하는 싱글턴 패턴(sigleton pattern)을 통해 이를 구현한다.
코틀린은 객체 선언 기능을 통해 싱글턴을 언어에서 기본 지원한다. 객체 선언은 클래스 선언과 그 클래스에 속한 단일 인스턴스의 선언을 합친 선언이다.
예를들어, 객체 선언을 사용해 회사 급여 대장을 만들 수 있다. 한 회사에 여러 급여 대장이 필요하지는 않을 테니 싱글턴을 쓰는게 정당해 보인다.
아래 예제를 통해 자바와 코틀린에서의 싱글턴 객체 생성을 비교해보자.
[EX] - 자바 싱글턴 객체
public class SingletonClass {
//1. static으로 선언된 객체를 담는 변수(instance)
private static SingletonClass instance;
public String sampleString = "Sample String"; //싱글톤에 집중하기 위해 public 으로 설정
private SingletonClass() { //생성자
}
public static synchronized SingletonClass getInstance() { //instance를 가져오는 메서드
//2. 만약 기존에 instance가 생성되어 있었다면 기존 instance 사용. 만약 초기화되지 않았다면 새로 생성
if (instance == null) {
instance =nw SingletonClass();
}
//3. instance 반환
return instance;
}
}
위의 자바 코드를 보게되면, 싱글톤 패턴을 구현하기 위해 너무 많은 코드(보일러 플레이트[여러곳에서 재사용되며, 반복적으로 비슷한 형태를 띄는 코드])가 쓰여있다. 이러한 방식은 가독성을 떨어트리며, 오류를 발생시킬 가능성을 높인다.
==> 이를 해결하기 위해 코틀린에서는 object 키워드를 이용해 싱글턴 패턴을 간편하게 구현할 수 있도록 돕는다.
[EX] - 코틀린 싱글턴 객체
object Payroll {
val allEmployees = arrayListOf<Person>()
fun calculateSalary() {
for (person in allEmployees){
}
}
}
객체 선언은 object 키워드로 시작한다. 객체 선언은 클래스를 정의하고 그 클래스의 인스턴스를 만들어서 변수에 저장하는 모든 작업을 다 한 문장으로 처리한다.
SingletonObject는 한 번만 생성되는 클래스의 인스턴스로 내부의 모든 값들 역시 한 번만 생성된다.
클래스와 마찬가지로 객체 선언 안에도 프로퍼티, 메서드, 초기화 블록 등이 들어갈 수 있다. 하지만 생성자는(주 생성자와 부 생성자 모두) 객체 선언에 쓸 수 없다.
==> 일반 클래스 인스턴스와 달리 싱글턴 객체는 객체 선언문이 있는 위치에서 생성자 호출없이 즉시 만들어지기 때문에 객체 선언에는 생성자 정의가 필요 없다. 변수와 마찬가지로 객체 선언에 사용한 이름뒤에 마침표(.)를 붙이면 객체에 속한 메서드나 프로퍼티에 접근할 수 있다.
[ 자바와 코틀린의 SingltonClass의 차이점은 Java 코드는 호출 될때 인스턴스가 생성되는 반면, 코틀린에서는 프로세스가 시작될 때 인스턴스가 생성된다. ]
object Payroll {
val allEmployees = arrayListOf<Person>()
fun calculateSalary() {
for (person in allEmployees){
println(person)
}
}
}
fun main(args: Array<String>){
Payroll.allEmployees.add(Person("secret",false))
Payroll.allEmployees.add(Person("secret2",false))
Payroll.calculateSalary()
}
객체 선언도 클래스나 인터페이스를 상속 할 수 있다. 프레임워크를 사용하기 위해 특정 인터페이스를 구현해야 하는데, 그 구현 내부에 다른 상태가 필요하지 않은 경우에 이런 기능이 유용하다.
예를들어, java.util.Comparator 인터페이스를 살펴보자.
Comparator 구현은 두 객체를 인자로 받아 그 중 어느 객체가 떠 큰 지 알려주는 정수를 반환한다. Comparator 안에는 데이터를 저장할 필요가 없다.
==> 따라서 어떤 클래스에 속한 객체를 비교할 때 사용하는 Comparator는 보통 클래스마다 단 하나씩만 있으면 된다. 따라서 Comparator 인스턴스를 만드는 방법으로는 객체 선언이 가장 좋은 방법이다.
구체적인 예제로 두 파일 경로를 대소문자 관계없이 비교해주는 Comparator를 구현해보자.
[EX] - 객체 선언을 사용해 Comparator 구현하기
package list4
import java.io.File
object CaseInsensitiveFileComparator : Comparator<File> {
override fun compare(file1: File, file2: File): Int {
return file1.path.compareTo(file2.path,ignoreCase = true)
}
}
fun main(args: Array<String>){
println(CaseInsensitiveFileComparator.compare(File("/User"),File("/user")))
}
0
일반 객체(클래스 인스턴스)를 사용할 수 있는 곳에서는 항상 싱글턴 객체를 사용할 수 있다. 예를 들면 이 객체를 Comparator를 인자로 받는 함수에게 인자로 넘길 수 있다.
fun main(args: Array<String>){
val files = listOf(File("/z"), File("/a"))
println(files.sortedWith(CaseInsensitiveFileComparator))
}
위의 예제는 전달받은 Comparator에 따라 리스트를 정렬하는 sortedWith 함수를 사용한다.
클래스 안에서 객체를 선언할 수도 있다. 그런 객체도 인스턴스는 단 하나뿐이다.
예를들어, 어떤 클래스의 인스턴스를 비교하는 Comparator를 클래스 내부에 정의하는게 더 바람직하다.
[EX] - 중첩 객체를 사용해 Comparator 구현하기
data class Person(val name: String){
object NameComparator : Comparator<Person> {
override fun compare(o1: Person, o2: Person): Int =
o1.name.compareTo(o2.name)
}
}
fun main(args: Array<String>){
val persons = listOf(Person("Bob"), Person("Alice"))
println(persons.sortedWith(Person.NameComparator))
}
[Person(name=Alice), Person(name=Bob)]
이제 클래스 안에 중첩된 객체 중에서도 독특한 객체(동반 객체)를 살펴보자.
■ 1-4-2. 동반 객체: 팩토리 메서드와 정적 멤버가 들어갈 장소
[ *팩토리패턴 : 팩토리 메소드 패턴에서는 객체를 생성하기 위한 인터페이스를 정의하는데, 어떤 클래스의 인스턴스를 만든 서브클래스에서 결정하게 만들게 하는 패턴 ]
- 객체를 생성하는 코드를 추상화하여 코드를 한곳에서 관리하지 않으면, 변화(생성,수정,삭제)가 발생 했을 때 해당 클라이언트 코드를 전부 수정해줘야 한다.
- 즉, 객체지향 디자인패턴 원칙 확장에 대해서는 열려고있고 변화에 대해서는 닫혀있어야 한다.
==> 때문에 변화가 일어날 수 있는 객체 생성 담당하는 클래스를 만들어 한곳에서 관리하여 결합도를 줄이기 위해 사용하는 패턴이다.
코틀린 클래스 안에는 정적인 멤버가 없다. 코틀린 언어는 자바 static 키워드를 지원하지 않는다.
==> 그 대신 패키지 수준의 최상위 함수와 객체 선언을 사용할 수 있다.
- 최상위 함수는 static 메소드를 대신할 수 있다.
- 객체 선언은 최상위 함수가 접근할 수 없는 클래스 내에 private 멤버 변수에 접근해야 할 때 사용할 수 있다.
클래스 내에서 object를 중첩 객체로 사용할 시 여러개의 object 중첩 객체를 사용 가능하다.
companion object {...} 사용할 수 있다.
companon object는 클래스 내에 하나만 생성 할 수 있다.
클래스 안에 정의된 객체 중 하나에 companion이라는 특별한 표시를 붙이면 그 클래스의 동반 객체로 만들 수 있다. 동반 객체의 프로퍼티나 메서드에 접근하려면 그 동반 객체가 정의된 클래스 이름을 사용한다. 이때 객체의 이름을 따로 지정할 필요가 없다.
그 결과 동반 객체의 멤버를 사용하는 구문은 자바의 정적 메서드 호출이나 정적 필드 사용 구문과 같아진다.
package list4
import java.io.File
data class Person(val name: String){
object NameComparator : Comparator<Person> {
override fun compare(o1: Person, o2: Person): Int =
o1.name.compareTo(o2.name)
}
}
class A {
companion object {
fun bar() {
println("Companion object called")
}
}
}
fun main(args: Array<String>){
A.bar()
}
동반 객체는 자신의 둘러싼 클래스의 모든 private 멤버에 접근할 수 있다.
==> 따라서 동반 객체는 팩토리 패턴을 구현하기 가장 적합한 위치다.
동반 객체(companion object)는 팩토리 패턴을 구현하는데 효과적이다.
클래스를 생성할 때 여러 생성자(constructor)를 마들어서 객체를 생성할 수 있지만 생성자가 많아지면 어떻게 클래스를 생성해야 될 지 햇갈릴 때가 많다. 이때 팩토리 패턴을 사용하면 클래스를 생성할 때 어떤 목적으로 만들때 필요한 생성자를 선택하는데 도움이 될 수 있다.
예제로 부 생성자가 2개 있는 클래스를 살펴보고, 다시 그 클래스를 동반 객체 안에서 팩토리 클래스를 정의하는 방식으로 변경해보자.
[ 리스트 4_14의 FacebookUser와 SubcribingUser 예제를 바탕으로 한다. 리스트 4_14에서 두 클래스 모두 User 클래스를 상속했지만 이제는 두 클래스를 한 클래스로 합치면서 사용자 객체를 생성하는 여러 방법을 제공하기로 결정했다. ]
[EX] - 부 생성자가 여럿 있는 클래스 정의하기
package list4
class User5 {
val nickname: String
constructor(emial: String){
nickname = emial.substringBefore('@')
}
constructor(facebookAccountId: Int) {
nickname = getFacebookName(facebookAccountId)
}
}
위의 로직을 표현하는 더 유용한 방법으로 클래스의 인스턴스를 생성하는 팩토리 메서드가 있다.
아래 예제는 생성자를 통해 User 인스턴스를 만들 수 없고 팩토리 메서드를 통해야만 한다.
[EX] - 부 생성자를 팩토리 메서드로 대신하기
package list4
class User6 private constructor(val nickname: String) { //주 생성자를 비공개로 만든다,
companion object { // 동반 객체를 선언한다.
//이메일로 닉네임을 뽑아 User6 생성
fun newSubscribingUser(email: String) = User6(email.substringBefore('@'))
// Id로 User6 생성
fun newFacebookUser(accountId: Int) = User6("${accountId}")
}
}
fun main(args: Array<String>){
//사용법
val subscribingUser = User6.newSubscribingUser("bob@gmail.com")
val facebookUser = User6.newFacebookUser(4)
println(subscribingUser.nickname)
}
bob
위의 코드를 보자.
클래스 이름을 사용해 그 클래스에 속한 동반 객체의 메소드를 호출 할 수 있다.
팩토리 메서드는 매우 유용하다.
- 동반 객체는 목적에 따라 팩토리 메서드 이름을 정할 수 있다. 이름을 정하지 않으면 자동으로 Companion이 이름으로 사용된다.
- 동반 객체도 인너페이스를 구현할 수 있다.
- 동반 객체에 확장함수를 넣을 수 있다.
- 동반 객체는 하위 클래스에서 오버라이드가 필요한경우에는 사용할 수 없다. [ 참고로 object 객체는 상속이 가능하다. ]
==> 동반 객체는 오버라이드 할 수 없기 때문이다.
이러한 경우에는 여러 생성자를 생성하는 것이 더 좋다. 아래 예제는 오버라이드 할 수 없는 동반 객체에 대한 예제다.
open class User {
val name:String
//부 생성자
constructor(email: String) {
this.name = email.substringBefore("@")
}
//부생성자
constructor(id:Int) {
this.name = "${id}"
}
}
//상속이 필요한 경우 부 생성자를 여러개 생성하는 편이 좋음
class UserTest : User {
constructor(email: String) : super(email)
constructor(id: Int) : super(id)
}
■ 1-4-3. 동반 객체를 일반 객체처럼 사용
동반 객체에 이름 붙이기
동반 객체는 클래스 안에 정의된 일반 객체다. 따라서 동반 객체에 이름을 붙이거나 동반 객체가 인터페이스를 상속하거나, 동반 객체 안에 확장 함수와 프로퍼티를 정의할 수 있다. 예를 하나 살펴보자.
[ 회사의 급여 명부를 제공하는 웹서비스를 만든다고 가정할때, 서비스에서 사용하기 위해 객체를 JSON으로 직렬화하거나 역직렬화해야 한다. 직렬화 로직을 동반 객체 안에 넣을 수 있다. ]
[EX] - 동반 객체에 이름 붙이기
package list4
class Person2(val name: String) {
companion object Loader { // 동반 객체에 이름을 넣는다.
fun fromJSON(jsonText: String) : Person2 = ...
}
}
fun main(args: Array<String>){
val person2 = Person2.Loader.fromJSON("${name: 'Dmitry'}")
}
companion object Loadder같은 방식으로 동반 객체에도 이름을 붙일 수 있다.
특별히 이름을 지정하지 않으면 동반 객체 이름은 자동으로 Companion이 된다.
동반 객체에서 인터페이스 구현
동반 객체도 인터페이스를 구현할 수 있다. 인터페이스를 구현하는 동반 객체를 참조할 때 객체를 둘러싼 클래스의 이름을 바로 사용할 수 있다.
시스템에 Peroson을 포함한 다양한 타입의 객체가 있다고 가정하자. 이 시스템에서는 모든 객체를 역직렬화를 통해 만들어야 하기 때문에 모든 타입의 객체를 생성하는 일반적인 방법이 필요하다. 이를 위해 JSON을 역직렬화하는 JSONFactory 인터페이스가 존재한다.
Person은 다음과 같이 JSONFactory 구현을 제공 할 수 있다.
[EX] - 동반 객체에서 인터페이스 구현하기
package list4
interface JSONFacntory<T> {
fun fromJson(jsonText: String) : T
}
class Person3(val name: String) {
companion object : JSONFacntory<Person3> {
override fun fromJson(jsonText: String): Person3 { //동반 객체가 인터페이스를 구현한다.
TODO("Not yet implemented")
}
}
}
이제 JSON으로부터 각 원소를 다시 만들어내는 추상 팩토리가 있다면 Person 객체를 그 팩토리에게 넘길 수 있다.
fun loadFromJSON<T>(factory: JSONFactory<T>) : T {
...
}
loadFromJSON(Person) //동반 객체의 인스턴스를 함수에 넘긴다
여기서 동반 객체가 구현한 JSONFactory의 인스턴스를 넘길 때 Person3 클래스의 이름을 사용했다는 점에 유의해라
동반 객체 확장
확장 함수를 사용하면 코드 기반의 다른곳에서 정의된 클래스의 인스턴스에 대해 새로운 메서드를 정의할 수 있다.
그렇다면 자바의 정적 메서드나 코틀린의 동반 객체 메서드처럼 기존 클래스에 대해 호출할 수 있는 새로운 함수를 정의하고 싶다면 어떻게 해야 할까?
클래스에 동반 객체가 있으면 그 객체 안에 함수를 정의함으로써 클래스에 대해 호출할 수 있는 확장 함수를 만들 수 있다. 구체적으로 C라는 클래스 안에 동반 객체가 있고 그 동반 객체 안에 func를 정의하면 외부에서는 fun()를 c.func()로 호출할 수 있다.
[EX] - 동반 객체에 대한 확장 함수 정의하기
package list4
class Person4(val firstName: String, val lastName: String) {
companion object{
//빈 동반 객체 선언 필요
}
}
// 클라이언트/서버 통신 모듈, 동반 객체에 대한 확장 함수
fun Person4.Companion.fromJson(json: String) : Person4 {
...
}
// 일반적인 확장함수
fun Person4.fromJSON(json: String) {
//...
}
val p = Person4.fromJson(json)
위의 코드를 보자 동반 객체 안에서 fromJSON 함수를 정의한 것처럼 fromJSON을 호출할 수 있다. 하지만 실제로 fromJSON은 클래스 밖에서 정의한 확장 함수다. 다른 보통 확장 함수처럼 fromJSON도 클래스 멤버 함수처럼 보이지만, 실제로는 멤버 함수가 아니다. 여기서 동반 객체에 대한 확장 함수를 작성할 수 있으려면 원래 클래스에 동반 객체를 꼭 선언해야 한다는 점에 주의하라. 설령 빈 객체라도 동반 객체를 선언해줘야 한다.
정리를 하자면 companion object는 동반 객체라고 해서 클래스 안에서 사용할 수 있는 object 객체인데, 한 개밖에 생성하지 못한다.
그렇기에 클래스를 생성할 때 여러 생성자(constructor) 를 만들어서 객체를 생성할 수 있지만, 생성자가 많아지면 어떻게 클래스를 생성해야될지 헷갈릴때가 많은데 이때 팩토리 패턴을 사용하면 클래스를 어떤 목적으로 필요한 생성자를 선택하는데 도움이 될 수 있다.
즉, 부 생성자(constructor)를 이용하지 않고 어떤 목적으로 만들때의 필요한 생성자를 선택하는데 도움이 될 수 있다.
이러한 동반객체의 특징은 상속 불가, 1개만 선언 가능, 동반 객체에 확장 함수 가능, 인터페이스 구현 가능, 이름 붙이기 가능하고 자신이 둘러싼 모든 private 함수까지 접근이 가능하다.
[ 쉽게 이해하기 위해서 https://zerogdev.blogspot.com/2019/07/kotlin-object.html 참고해라. ]
이제는 코틀린에서 object 키워드를 사용하는 또 다른 기능인 객체 식(object expression)에 대해 살펴보자.
■ 1-4-4. 객체 식: 무명 내부 클래스를 다른 방식으로 작성
[ 우선 자바의 익명객체(익명클래스) 부터 이해하고 보는게 이해하기 훨 쉽다. https://limkydev.tistory.com/226 ]
object 키워드를 사용하여 익명 객체(무명 객체, anonymous object)를 정의한다.
코틀린은 명시적인 선언없이 객체를 바로 생성할 수 있는 식을 제공한다. 객체 식은 자바 익명 클래스와 아주 비슷하다. 다음 코드를 보자.
fun main(args: Array<String>) {
fun midPoint(xRange: IntRange, yRange: IntRange) = object {
val x = (xRange.first + xRange.last) / 2
val y = (yRange.first + yRange.last) / 2
}
val midPoint = midPoint(1..5, 2..6)
println("${midPoint.x}, ${midPoint.y}") //(3,4)
}
객체 식은 이름이 없는 객체 정의처럼 보인다. 그리고 객체 식도 식이므로 객체식이 만들어내는 값을 변수 대입할 수 있다. 클래스나 객체 식과 달리 객체를 함수 안에 정의할 수는 없다.
진짜 쉽게 생각하면 내가 객체를 만들기 위해 명시적인 선언없이 object를 이용하여 원하는 클래스 타입으로 바로 객체를 생성하여 이용하는 것이다.
무명객체에 자세히 알아보자.
object 키워드를 싱글턴과 같은 객체를 정의하고 그 객체에 이름을 붙일 때만 사용하지 않는다.
무명 객체(anonymous object)를 정의할 때도 object 키워드를 쓴다. 무명 객체는 자바의 무명 내부 클래스를 대신한다.
무명 객체란 익명 클래스로부터 생성되는 객체를 뜻한다. 익명 클래스는 다른 클래스들과 달리 이름을 가지지 않는 클래스다.
==> 즉, 이름을 가지지 않는 익명 클래스로부터 무명 객체를 생성 할 수 있다.
무명 객체가 필요한 이유를 먼저 공부하고 코틀린으로 구현을 해보자.
무명 객체가 필요한 이유
무명 객체는 클래스가 한번만 활용되어야 하는 경우 매우 유용하다.
==> 만약 한번만 활용되어야 하는데 매번 클래스를 생성하면 너무 클래스가 많아지는 불편함이 있기 때문이다.
예를들어 설명을 해보겠다.
1. 버튼클릭 리스너 인터페이스가 있다.
interface ButtonClickListener {
fun onButtonClicked();
}
2. Button 클래스에는 버튼클릭 리스너 인터페이스를 구현한 클래스 타입(privage val buttonClickListener : ButtonClickListener)이 매개변수로 들어가고 buttonClickCallback() 메소드가 있는데 이 메소드는 버튼클릭 리스너 인터페이스를 구현한 클래스 타입이 구현한 onButtonClicked() 메소드를 호출한다.
class Button(privae val buttonClickListener: ButtonClickListener) {
fun buttonClickCallback() {
buttonClickListener.onButtonClicked()
}
}
3. 버튼 클래스에 들어갈 매개변수를 구현해보자. 예를 들어 OK 버튼을 클릭했을 때 "OK clicked" 라는 단어를 프린트해주는 클래스를 만든다고 생각해보자.
class OKButtonClickListener : ButtonClickListener {
override fun onButtonClicked() {
println("Ok Clicked")
}
}
4. Button 클래스로부터 객체를 생성하고 그 매개변수에 ButtonClickListener를 구현한 타입인 OKButtonClickListener를 넣어준다.
fun main() {
Button(OKButtonClickListener())
}
위의 버튼 예제를 보면서, 만약 이 OKButtonClickListener가 이 버튼에서만 활용된다면 저 클래스를 하나 더 생성하는것보다 바로 저 자리에 객체를 즉시 작성해 생성하는 것이 유용할 수 있다.
무명 객체(익명 클래스) 구현하기
무명 객체를 만드는 것을 이해기 위해서는 우선 클래스로부터 객체를 생성하는 방식을 알아야 한다.
clss [클래스명] : [인터페이스 명] {
[ 인터페이스에서 구현해야 하는 fun]
}
class OKButtonClickListener : ButtonClickListener {
override fun onButtonClicked() {
println("Ok Clicked")
}
}
위에 코드로부터 클래스를 생성하고 이 클래스를 이용해 클래스명()을 써서 객체를 생성해 사용한다.
fun main(args: Array<String>){
Button(OKButtonCLickListener())
}
클래스로부터 객체를 만드는 방법을 이해했으니 무명 클래스로부터 무명 객체를 만들어보자~!^^
무명 클래스는 다음의 형식을 통해 만들 수 있다.
object : [인터페이스 명] {
[인터페이스에서 구현해야 하는 fun]
}
클래스와 매우 유사한데, class와 다른 점은 class [클래스명] 대신에 object가 들어갔다는 점이다.
object 키워드가 클래스의 역할을 한다.
==> 즉, object는 이름이 없는 클래스라 해서 익명 클래스라 불린다.
또한 이렇게 생성된 익명 클래스는 바로 자기자신을 객체로 생성한다. 이 객체 또한 이름이 없으므로 무명객체라 불린다.
위의 OKButtonClickListener() 객체와 같은 역할을 하는 무명 객체는 다음과 같이 만들 수 있다. 이 객체는 클래스 없이 objcect : 인터페이스 타입을 통해 즉시 만들어졌고 이 객체를 필요로 하는 어떠한 함수의 매개변수에 이용 할 수 있다.
object : ButtonClickListener {
overrid fun onButtonCLicked() {
println("OK Clicked")
}
}
위의 Button의 파라미터 자리에 만든 무명 객체를 넣어보자. 이 무명객체는 위의 OKButtonClickListener()와 똑같은 역할을 하게 된다.
fun main(args: Array<String>){
val buttonclick = object : ButtonClickListener {
override fun onButtonCLicked() {
println("무명 객체로 만들었습니다.(무명객체를 변수에 넣음)")
}
}
Button5(object : ButtonClickListener { // 이것은 직접 무명 객체를 생성해 매겨변수에 이용
override fun onButtonCLicked() {
println("무명 객체로 만들었습니다.(무명객체 직접 넣음)")
}
}).buttonClickCallback()
Button5(buttonclick).buttonClickCallback() //이것은 무명 객체를 변수에 집어넣고 그것을 매개변수에 이용
}
무명 객체로 만들었습니다.(무명객체 직접 넣음)
무명 객체로 만들었습니다.(무명객체를 변수에 넣음)
다른 예제로 자바에서 흔히 무명 내부 클래스로 구현하는 이벤트 리스너를 코틀린에서 구현해보자.
[EX] - 무명 객체로 이벤트 리스너 구현하기
package list4
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
window.addMouseListener (
object : MouseAdapter() { //MouseAdapter를 확장하는 무명 객체를 선언한다.
override fun mouseClicked(e: MouseEvent?) { //MouseAdapter의 메서드를 오버라이드 한다.
super.mouseClicked(e)
}
override fun mouseEntered(e: MouseEvent?) { //MouseAdapter의 메서드를 오버라이드 한다.
super.mouseEntered(e)
}
}
)
위의 코드는 객체 선언에서와 비슷하다. 한 가지 유일한 차이는 객체 이름이 빠졌다는 점이다.
객체 식은 클래스를 정의하고 그 클래스에 속한 인스턴스를 생성하지만, 그 클래스나 인스턴스에 이름을 붙이지는 않는다.
==> 이런 경우 보통 함수를 호출하면서 인자로 무명 객체를 넘기기 때문에 클래스와 인스턴스 모두 이름이 필요하지 않다.
하지만 객체에 이름을 붙여야 한다면 변수에 무명 객체를 대입하면 된다.
//변수에 무명 객체 대입
val listener = object : MouseAdapter() {
// MouseAdapter의 메서드 오버라이드
override fun mouseClicked(e: MouseEvent?) {
super.mouseClicked(e)
}
// MouseAdapter의 메서드 오버라이드
override fun mouseEntered(e: MouseEvent?) {
super.mouseEntered(e)
}
}
한 인터페이스만 구현하거나 한 클래스만 확장할 수 있는 자바의 무명 내부 클래스와 달리 코틀린 무명 클래스는 여러 인터페이스를 구현하거나 클래스를 확장하면서 인터페이스를 구현할 수 있다.
객체 선언과 달리 무명 객체는 싱글턴이 아니다. 객체 식이 쓰일 때마다 새로운 인스턴스가 생성된다.
자바의 무명 클래스와 같이 객체 식 안의 코드는 그 식이 포함된 함수의 변수에 접근할 수 있다. 하지만 자바와 달리 final이 아닌 변수를 객체 식 안에서 사용할 수 있다.
==> 따라서 객체 식 안에서 그 변수의 값을 변경할 수 있다. 예를 들어 어떤 윈도우가 호출된 횟수를 리스너에서 누적하게 만들 수 있다.
[EX] - 무명 객체 안에서 로컬 변수 사용하기
package list4
import java.awt.Window
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
fun countClicks(window: Window) {
var clickCount = 0 //로컬 변수를 정의한다.
window.addMouseListener(object : MouseAdapter(){
override fun mouseClicked(e: MouseEvent?) {
clickCount++ // final이 아닌 로컬 변수의 값을 변경한다.
}
})
// ...
}
주의 할 점 및 당연한 사실이겠으나, 객체 선언과 달리 무명 객체는 싱글턴이 아니다. 객체 식이 쓰일 대마다 새로운 인스턴가 생성된다.
■ 2. 정리 ■
- 코틀린의 인터페이스는 자바 인터페이스와 비슷하지만 디폴트 구현을 포함할 수 있고, 프로퍼티도 포함할 수 있다.(자바에서는 불가능)
- 모든 코틀린 선언은 기본적으로 final이며 public이다.
- 선언이 final이 되지 않게 만들려면(상속과 오버라이딩이 가능하게 하려면) 앞에 open을 붙여야 한다.
- iternal 선언은 같은 모듈 안에서만 볼 수 있다.
- 중첩 클래스는 기본적으로 내부 클래스가 아니다. 바깥쪽 클래스에 대한 참조를 중첩 클래스 안에 포함시키려면 inner키워드를 중첩 클래스 선언 앞에 붙여서 내부 클래스로 만들어야 한다.
- sealed 클래스를 상속하는 클래스를 정의하려면 반드시 부모 클래스 정의 안에 중첩(또는 내부) 클래스로 정의해야 한다.
- 초기화 블록과 부 생성자를 활용해 클래스 인스턴스를 더 유연하게 초기화할 수 있다.
- field 식별자를 통해 프로퍼티 접근자(게터와 세터)안에서 프로퍼티의 데이터를 저장하는데 쓰이는 뒷받침하는 필드를 참조 할 수 있다.
- 데이터 클래스를 사용하면 컴파일러가 equals, hashCode, toString, copy등의 메서드를 자동으로 생성해준다.
- 객체 선언을 하면 코틀린답게 싱글턴 클래스를 정의할 수 있다.
- 동반 객체는 자바의 정적 메서드와 필드 정의를 대신한다.
- 동반 객체도 다른 (싱글턴) 객체와 마찬가지로 인터페이스를 구현할 수 있다. 외부에서 동반 객체에 대한 확장 함수와 프로퍼티를 정의할 수 있다.
- 코틀린의 객체 식은 자바의 무명 내부 클래스를 대신한다. 하지만 코틀린 객체식은 여러 인스턴스를 구현하거나 객체가 포함된 영역에 있는 변수의 값을 변경할 수 있는 등 자바 무명 내부 클래스보다 더 많은 기능을 제공한다.
'Kotlin > Kotlin in action' 카테고리의 다른 글
6장 코틀린 타입 시스템 (0) | 2022.10.04 |
---|---|
5장 람다로 프로그래밍 (1) | 2022.09.29 |
3장 함수 정의와 호출 (1) | 2022.09.23 |
2장 코틀린 기초 (1) | 2022.09.22 |
1장 코틀린이란 무엇이며, 왜 필요한가? (1) | 2022.09.21 |