2. 클래스
원제 : Java to Kotlin Classes
자바에서 클래스는 코드를 조작하는 기본 단위이다.
자바의 클래스를 코틀린의 클래스로 변환해보고, 어떤 차이점이 있는지 알아보자.
2.1. 간단한 값 타입
원제 : A Simple Value Type
우리는 먼저 아래 EmailAddress
라는 자바 클래스를 코틀린으로 변경하려고 한다.
EmailAddress
는 이메일 주소의 local part와 domain을 나누어 저장한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 public class EmailAddress { private final String localPart; private final String domain; public static EmailAddress parse (String value) { var atIndex = value.lastIndexOf('@' ); if (atIndex < 1 || atIndex == value.length() - 1 ) throw new IllegalArgumentException ( "EmailAddress must be two parts separated by @" ); return new EmailAddress ( value.substring(0 , atIndex), value.substring(atIndex + 1 ) ); } public EmailAddress (String localPart, String domain) { this .localPart = localPart; this .domain = domain; } public String getLocalPart () { return localPart; } public String getDomain () { return domain; } @Override public boolean equals (Object o) { if (this == o) return true ; if (o == null || getClass() != o.getClass()) return false ; EmailAddress that = (EmailAddress) o; return localPart.equals(that.localPart) && domain.equals(that.domain); } @Override public int hashCode () { return Objects.hash(localPart, domain); } @Override public String toString () { return localPart + "@" + domain; } }
EmailAddress
는 local part와 domain을 감싸기만하고 별도의 연산은 없는 간단한 코드이다.
두 개의 문자열을 입력받아, 하나의 포맷을 출력해주는 것치곤 많은 코드가 필요하며, 필드가 추가될때마다 오버라이딩 비용도 계속해서 투입되어야 한다.
이제 이 클래스를 코틀린으로 변환해보자.
IDE의 기능으로 변환하면 아래와 같은 형태로 변환된다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class EmailAddress ( val localPart: String, val domain: String, ) { override fun equals (o: Any ?) : Boolean { if (this === o) return true if (o == null || javaClass != o.javaClass) return false val that = o as EmailAddress return localPart == that.localPart && domain == that.domain } override fun hashCode () : Int { return Objects.hash(localPart, domain) } override fun toString () : String { return "$localPart @$domain " } companion object { @JvmStatic fun parse (value: String ) : EmailAddress { val atIndex = value.lastIndexOf('@' ) require(!(atIndex < 1 || atIndex == value.length - 1 )) { "EmailAddress must be two parts separated by @" } return EmailAddress( value.substring(0 , atIndex), value.substring(atIndex + 1 ) ) } } }
코틀린의 클래스는 하나의 주 생성자와 복수 개의 부 생성자를 구현할 수 있다.
1 2 3 4 class EmailAddress ( val localPart: String, val domain: String, )
주 생성자는 위 코드처럼 클래스 이름 뒤에 괄호로 둘러쌓아서 표현한다.
파라미터 앞에 붙은 val
은 파라미터를 클래스의 프로퍼티로 취급하게 해준다.
참고 domain
프로퍼티 끝에도 콤마(,)가 있는 것을 볼 수 있다. 오타처럼 보이는 이것은 사실 Trailing Commas 라고 불리는 코틀린의 권장사항이다. 코틀린 1.4부터 지원되며 가독성 및 VCS에서의 diff 비교를 간소화해준다.kotlinlang.org - Coding conventions
위 한 줄의 코드는 아래 자바 코드를 전부 대체해준다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 private final String localPart; private final String domain;public EmailAddress (String localPart, String domain) { this .localPart = localPart; this .domain = domain; } public String getLocalPart () { return localPart; } public String getDomain () { return domain; }
그 다음으로 눈여겨볼 코드는 아래 코드이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 companion object { @JvmStatic fun parse (value: String ) : EmailAddress { val atIndex = value.lastIndexOf('@' ) require(!(atIndex < 1 || atIndex == value.length - 1 )) { "EmailAddress must be two parts separated by @" } return EmailAddress( value.substring(0 , atIndex), value.substring(atIndex + 1 ) ) } }
오히려 더 복잡해졌다는 느낌을 주는데, 코틀린은 static
키워드가 없기때문이다.
정적 메서드 영역은 companion object
라는 동반 객체 블럭 안에 위치시킴으로서 동일하게 구현할 수가 있다.
@JvmStatic
어노테이션도 추가된 것을 볼 수 있는데, 이는 자바에서 해당 메서드(여기서는 parse()
)를 호출할 때
마치 자바의 정적 메서드를 호출하듯이 쓸 수 있게 해준다.
실제로 아래와 같이 호출할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class EmailAddressTests { @Test public void parsing () { assertEquals( new EmailAddress ("fred" , "example.com" ), EmailAddress.parse("fred@example.com" ) ); } @Test public void parsingFailures () { assertThrows( IllegalArgumentException.class, () -> EmailAddress.parse("@" ); assertThrows( IllegalArgumentException.class, () -> EmailAddress.parse("fred@" ) ); assertThrows( IllegalArgumentException.class, () -> EmailAddress.parse("@example.com" ) ); assertThrows( IllegalArgumentException.class, () -> EmailAddress.parse("fred_at_example.com" ) ); } @Test public void parsingWithAtInLocalPart () { assertEquals( new EmailAddress ("\"b@t\"" , "example.com" ), EmailAddress.parse("\"b@t\"@example.com" ) ); } }
그 다음으로 JavaBean의 Getter에 대해서도 생각해보자.
코틀린의 클래스는 프로퍼티에 대해 자동으로 getter를 생성해준다.
따라서 프로퍼티 domain
에 대해서는 자바에서 아래와 같이 호출할 수 있다.
1 2 3 4 5 6 public class Marketing { public static boolean isHotmailAddress (EmailAddress address) { return address.getDomain().equalsIgnoreCase("hotmail.com" ); } }
이후 클래스 앞에 data
키워드를 붙여서 데이터 클래스로 선언하면 그 자체로 값 타입으로 처리되어 최종적으로 아래와 같이 코드가 줄어든다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 data class EmailAddress ( val localPart: String, val domain: String ) { override fun toString () : String { return "$localPart @$domain " } companion object { @JvmStatic fun parse (value: String ) : EmailAddress { val atIndex = value.lastIndexOf('@' ) require(!(atIndex < 1 || atIndex == value.length - 1 )) { "EmailAddress must be two parts separated by @" } return EmailAddress( value.substring(0 , atIndex), value.substring(atIndex + 1 ) ) } } }
2.2. 데이터 클래스의 한계
원제 : The Limitations of Data Classes
2.1에서 변환한 데이터 클래스는 캡슐화를 제공하지 않는다는 단점이 있다.
데이터 클래스에 대해 컴파일러가 equals()
, hashCode()
, toString()
메서드를 생성해주는ㄴ데,
추가로 copy()
메서드도 생성해준다.
예제를 통해 살펴보도록 하자.
먼저 아래와 같이 EmailAddress
객체를 하나 만든다.
1 2 3 val postmasterEmail = customerEmail.copy( localPart = "postmaster" , )
특정 상태만 바꾼채로 객체를 복제할 수 있다는 건 유용한 것은 분명하다.
다만, copy()
메서드 자체가 내부 상태를 직접 접근하도록 허용하는 것이므로 불변 조건을 꺠뜨리게 된다.
좀 더 깊은 이해를 위해 Money
클래스 예제를 살펴보자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 public class Money { private final BigDecimal amount; private final Currency currency; private Money (BigDecimal amount, Currency currency) { this .amount = amount; this .currency = currency; } public static Money of (BigDecimal amount, Currency currency) { return new Money ( amount.setScale(currency.getDefaultFractionDigits()), currency); } public static Money of (String amountStr, Currency currency) { return Money.of(new BigDecimal (amountStr), currency); } public static Money of (int amount, Currency currency) { return Money.of(new BigDecimal (amount), currency); } public static Money zero (Currency userCurrency) { return Money.of(ZERO, userCurrency); } public BigDecimal getAmount () { return amount; } public Currency getCurrency () { return currency; } @Override public boolean equals (Object o) { if (this == o) return true ; if (o == null || getClass() != o.getClass()) return false ; Money money = (Money) o; return amount.equals(money.amount) && currency.equals(money.currency); } @Override public int hashCode () { return Objects.hash(amount, currency); } @Override public String toString () { return amount.toString() + " " + currency.getCurrencyCode(); } public Money add (Money that) { if (!this .currency.equals(that.currency)) { throw new IllegalArgumentException ( "cannot add Money values of different currencies" ); } return new Money (this .amount.add(that.amount), this .currency); } }
이제 이걸 코틀린으로 변환해보자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 class Money private constructor ( val amount: BigDecimal, val currency: Currency ) { override fun equals (o: Any ?) : Boolean { if (this === o) return true if (o == null || javaClass != o.javaClass) return false val money = o as Money return amount == money.amount && currency == money.currency } override fun hashCode () : Int { return Objects.hash(amount, currency) } override fun toString () : String { return amount.toString() + " " + currency.currencyCode } fun add (that: Money ) : Money { require(currency == that.currency) { "cannot add Money values of different currencies" } return Money(amount.add(that.amount), currency) } companion object { @JvmStatic fun of (amount: BigDecimal , currency: Currency ) : Money { return Money( amount.setScale(currency.defaultFractionDigits), currency ) } @JvmStatic fun of (amountStr: String ?, currency: Currency ) : Money { return of(BigDecimal(amountStr), currency) } @JvmStatic fun of (amount: Int , currency: Currency ) : Money { return of(BigDecimal(amount), currency) } @JvmStatic fun zero (userCurrency: Currency ) : Money { return of(BigDecimal.ZERO, userCurrency) } } }
비공개 생성자가 있으므로, 코틀린도 주생성자를 private
으로 처리하였다.
추가로 of()
메서드는 @JvmStatic
어노테이션으로 자바에서의 동일 호출을 보장한다.
이제 다시 데이터 클래스로 변환해보자.
1 2 3 4 5 6 7 data class Money private constructor ( val amount: BigDecimal, val currency: Currency ) { }
data
키워드를 선언하면 아래와 같은 경고 메시지가 출력 된다.
1 Private data class constuctor is exposed via the generated 'copy' method.
컴파일러가 데이터 클래스로 바꾸면 비공개 생성자로 작성하더라도 생성자가 노출된다고 경고하는 것을 확인할 수 있다.
이처럼 데이터 클래스에 있는 copy()
메서드는 항상 public
이기 때문에 불변 조건을 위배하는 Money
를 생성할 수 있게 된다.