Blog

이펙티브 코틀린(6)

이펙티브 코틀린(6)

클래스는 객체 지향 프로그래밍(OOP) 패러다임에서 가장 중요한 추상화이다

6-1. 상속보다는 컴포지션을 사용하라

상속은 강력한 기능으로 is-a 관계의 객체 계층 구조를 만들기 위해 설계되었다
상속은 관계가 명확하지 않을 때 사용하면 여러가지 문제가 발생할 수 있기 때문에 이런 경우에는 상속보다 컴포지션을 사용하는것이 좋다

간단한 행위 재사용

상속을 사용하여 행위 재사용
상속은 하나의 클래스만을 대상으로 할 수 있다
상속을 사용해서 행위를 추출하다 보면, 많은 함수를 갖는 거대한 클래스가 만들어지고 굉장히 깊고 복잡한 계층 구조가 생성된다
상속은 클래스의 모든 것을 가져오게 된다
불필요한 함수를 갖는 클래스가 생성될 수 있다
인터페이스 분리 원칙 (ISP, Interface Segregation Principle) 위반
상속은 이해하기 쉽지 않다
메서드의 작동 방식을 이해하기 위해 슈퍼 클래스를 여러번 확인해야 한다면 이해하기가 어려워진다
예시 코드
컴포지션 (composition)
객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용
예시 코드
컴포지션을 작성하여 적절하게 처리하는 것이 조금 어려울 수도 있어 컴포지션보다 상속을 선호하는 경우가 많다
하지만, 이런 추가 코드로 인해서 코드를 읽는 살마들이 훨씬 더 쉽게 메서드 실행을 예측하고 자유롭게 사용할 수 있다

모든 것을 가져올 수밖에 없는 상속

상속은 슈퍼클래스의 메서드, 제약, 행위 등 모든 것을 가져온다
상속은 객체의 계층 구조를 나타낼 때 굉장히 좋은 도구이다
하지만, 일부분을 재사용하기 위한 목적으로는 적합하지 않다
예시 코드
컴포지션이 무조건 좋다는것은 아니다.
타입 계층 구조를 표현해야 한다면, 인터페이스를 황횽해서 다중 상속을 하는 것이 좋을 수 있다

캡슐화를 깨를 상속

상속을 활용할 때는 외부 뿐만 아니라 내부적으로 이를 어떻게 활용하는지도 중요하다
내부적인 구현 방법 변경에 의해서 클래스의 캡슐화가 깨질 수 있다
예시 코드
위임 패턴
클래스가 인터페이스를 상속받게 하고, 포함한 객체의 메서드들을 활용해서 인터페이스에서 정의한 메서드를 구현하는 패턴
이렇게 구현된 메서드를 포워딩 메서드 (forwarding method) 라고 부른다
예시 코드
예시 코드 (by kotlin)
다형성이 필요한데, 상속된 메서드를 직접 활용하는 것이 위험할 때는 이와 같은 위임 패턴을 사용하는 것이 좋다
하지만 사실 일반적으로 다형성이 그렇게까지 필요한 경우는 없다
단순하게 컴포지션을 활용하면 해결되는 경우가 굉장히 많다
컴포지션은 재사용하기 쉽고, 더 많은 유연성을 제공한다

오버라이딩 제한하기

상속용으로 설계되지 않은 클래스를 상속하지 못하게 하려면 final 을 사용하면 된다
상속은 허용하지만, 메서드는 오버라이드하지 못하게 만들고 싶은 경우에는 메서드에 open 키워드를 사용하면 된다
예시 코드

정리

컴포지션은 안전하다
다른 클래스의 내부적인 구현에 의존하지 않고, 외부에서 관찰되는 동작에만 의존한다
컴포지션은 유연하다
상속은 한 클래스를 대상으로 할 수 있지만, 컴포지션은 여러 클래스를 대상으로 사용할 수 있다
컴포지션은 필요한 것만 받을 수 있다
슈퍼클래스의 동작을 변경하면, 서브클래스의 동작도 큰 영향을 받지만, 컴포지션은 제한적이다
컴포지션은 명시적이다
상속은 리시버를 따로 지정하지 않아도 되지만, 컴포지션은 명시적으로 활용할 수 밖에 없다
컴포지션은 생각보다 번거롭다
상속을 사용할 때보다 코드를 수정해야 하는 경우가 더 많다
상속은 다형성을 활용할 수 있다
상속해서 개발하게 된다면 굉장히 편리하게 활용될 수 있지만, 이는 코드에 제한을 걸게 된다
상속을 사용한 경우 슈퍼클래스와 서브클래스의 규약을 항상 잘 지켜서 코드를 작성해야 한다
상속은 명확한 is-a 관계일 때 상속을 사용하는것이 좋다
슈퍼클래스의 모든 단위테스트는 서브클래스로도 통과할 수 있어야 한다
상속을 위해 설계되지 않은 메서드는 final 로 만들어 두는 것이 좋다

6-2. 데이터 집합 표현에 data 한정자를 사용하라

data 한정자를 붙이면 아래의 몇가지 함수가 자동으로 생성된다
toString
클래스의 이름과 기본 생성자 형태로 모든 프로퍼티와 값을 출력
equals, hashCode
기본 생성자의 프로퍼티가 같은지 확인
copy
객체를 얕은 복사
객체가 immutable 하다면 깊은 복사한 객체가 필요 없다
componentN
위치를 기반으로 객체를 해제할 수 있게 해준다
장점
변수의 이름을 원하는대로 지정할 수 있다
List, Map.Entry 등의 원하는 형태로도 객체를 해제할 수 있다
단점
위치를 잘못 지정하면, 다양한 문제가 발생할 수 있다
흔히, 위치 순서를 혼동해서 객체를 잘못 해제하는 문제가 발생한다
예시 코드

튜플 대신 데이터 클래스 사용하기

데이터 클래스는 튜플보다 많은 기능을 제공한다
코틀린의 튜플은 Serializable 을 기반으로 만들어지며, toString 을 사용할 수 있는 제네릭 데이터 클래스이다
예시 코드
튜플을 사용하는 경우
값에 간단하게 이름을 붙일 때
예시 코드
표준 라이브러리에서 볼 수 있는 것처럼 미리 알 수 없는 aggregate 를 표현할 때
예시 코드
데이터 클래스 장점
함수의 리턴 타입이 더 명확해진다
리턴 타입이 더 짧아지며, 전ㄷ라하기 쉬워진다
사용자가 데이터 클래스에 적혀 있는 것과 다른 이름을 활용해 변수를 해제하면 경고를 출력한다
코틀린에서 클래스는 큰 비용없이 사용할 수 있는 좋은 도구이다

6-3. 연산 또는 액션을 전달할 때는 인터페이스 대신 함수 타입을 사용하라

대부분 프로그래밍 언어에서는 함수 타입이라는 개념이 없다
그래서 연산 또는 액션을 전달할 때 메서드가 하나만 있는 인터페이스를 활용한다
이러한 인터페이스를 SAM (Single-Abstract Method) 라고 부른다
예시 코드
함수 타입을 사용한 예시 코드
SAM 의 장점은 ‘아규먼트에 이름이 붙어 있는 것' 이라고 얘기할 수 있지만, 타입별칭 (type alias) 를 사용하면 함수 타입도 이름을 붙일 수 있다
예시 코드

언제 SAM 을 사용해야 할까?

코틀린이 아닌 다른 언어에서 사용할 클래스를 설계할 때 사용한다
자바에서는 인터페이스가 더 명확하다
다른 언어(자바 등) 에서 코틀린의 함수 타입을 사용하려면, Unit 을 명시적으로 리턴하는 함수가 필요하다
예시 코드

6-4. 태그 클래스보다는 클래스 계층을 사용하라

상수 모드(constant) 를 태그(tag) 라고 부르며, 태그를 포함한 클래스를 태그 클래스(tagged class) 라고 부른다
서로 다른 책임을 한 클래스에 태그로 구분해서 넣으면 문제가 발생한다
한 클래스에 여러 모드를 처리하기 위한 상용구(boilerplate) 가 추가된다
여러 목적으로 사용해야 하므로 프로퍼티가 일관적이지 않게 사용될 수 있으며, 더 많은 프로퍼티가 필요하다
요소갸ㅏ 여러 목적을 가지고, 요소를 여러 방법으로 설정할 수 있는 경우에는 상태의 일관성과 정확성을 지키기 어렵다
팩토리 메서드를 사용해야 하는 경우가 많다
그렇지 않으면 객체가 제대로 생성되었는지 확인하는 것 자체가 굉장히 어렵다
예시 코드
코틀린은 일반적으로 태그 클래스보다 sealed 클래스를 많이 사용한다
한 클래스에 여러 모드를 만드는 방법 대신에, 각각의 모드를 여러 클래스로 만들고 타입 시스템과 다형성을 활용한다
클래스에는 sealed 한정자를 붙여서 서브 클래스 정의를 제한한다
예시 코드

sealed 한정자

sealed 한정자는 외부 파일에서 서브클래스를 만드는 행위 자체를 모두 제한한다
외부에서 추가적인 서브클래스를 만들 수 없으므로, 타입이 추가되지 않을 거라는게 보장된다
when 을 사용할 때 else 브랜치를 따로 만들 필요가 없다
when 은 모드를 구분해서 다른 처리를 만들 때 굉장히 편리하다
예시 코드
abstract 클래스는 계층에 새로운 클래스를 추가할 수 있는 여지를 남긴다
클래스의 서브클래스를 제어하려면, sealed 한정자를 사용해야 한다
abstract 는 상속과 관련된 설계를 할 때 사용한다

태그 클래스와 상태 패턴의 차이

상태 패턴: 객체의 내 부 상태가 변화할 때, 객체의 동자깅 변하는 소프트웨어 디자인 패턴
태그 클래스와 상태 패턴을 혼동하면 안된다
상태 패턴은 프론트엔드 Controller, Presenter, View-Model 을 설계할 때 많이 사용된다
예시 코드
상태는 더 많은 책임을 가진 큰 클래스이다
상태는 변경할 수 있다
구체 상태 (concreate state) 는 객체를 활용해서 표현하는 것이 일반적이며, 태그 클래스보다는 sealed 클래스 계층으로 만든다
immutable 객체로 만들고, 변경해야 할 때마다 state 프로퍼티를 변경하게 만든다
View 에서 이러한 state 의 변화를 관찰(observe) 한다
예시 코드

정리

코틀린에서는 태그 클래스보다 타입 계층을 사용하는 것이 좋다
일반적으로 타입 계층을 만들 때는 sealed 클래스를 사용한다
타입 계층과 상태 패턴은 실질적으로 함께 사용되는 협력 관계이다

6-5. equals 의 규약을 지켜라

동등성

구조적 동등성 (structural equality)
equals 메서드와 이를 기반으로 만들어진 == 연산자로 확인하는 동등성
a 가 nullable 이 아니라면 a == ba.equals(b) 로 변환되고, a 가 nullable 이면 a?.equals(b) ?: (b === null) 로 변환된다
레퍼런스적 동등성 (referential equality)
=== 연산자로 확인하는 동승성
두 피연산자가 같은 객체를 가르키면 true 를 반환
equals 는 모든 클래스의 슈퍼클래스인 Any 에 구현되어 있으므로 모든 객체에서 사용할 수 있다
연산자를 사용해서 다른 타입의 두 객체를 비교하는 것은 허용하지 않는다
예시 코드
같은 타입을 비교하거나, 상속 관계를 갖는 경우에는 비교가 가능하다
예시 코드

equals 가 필요한 이유

예시 코드
일반적으로 데이터 모델을 표현할 대는 data 한정자를 붙인다
data 한정자를 사용해서 데이터 클래스로 사용하면 기본 생성자의 프로퍼티가 같은 객체를 손쉽게 비교할 수 있다
예시 코드
데이터 클래스의 동등성은 모든 프로퍼티가 아닌 일부 프로퍼티만 비교해야 할 때도 유용하다
예시 코드
data class 에 기본 생성자에 선언되지 않은 프로퍼티는 copy 메서드로 복사되지 않는다
data 한정자를 기반으로 동등성의 동작을 조작할 수 있으므로, 일반적으로 코틀린에서는 equals 를 직접 구현할 필요가 없다
equals 를 직접 구현해야 하는 경우
기본적으로 제공되는 동작과 다른 동작을 해야 하는 경우
일부 프로퍼티만으로 비교해야 하는 경우
data 한정자를 붙이는 것을 원하지 않거나, 비교해야 하는 프로퍼티가 기본 생성자에 없는 경우

equals 의 규약

구현 요구 사항
반사적 (reflexive) 동작
x 가 null 이 아닌 값이라면, x.equals(x) 는 true 를 반환해야 한다
예시 코드
대칭적 (symmetric) 동작
x 와 y 가 null 이 아닌 값이라면, x.equals(y)y.equals(x) 와 같은 결과를 반환해야 한다
예시 코드
연속적 (transitive) 동작
x, y, z 가 null 이 아닌 값이고 x.equals(y)y.equals(z) 가 true 이면 x.equals(z) 도 true 여야 한다
예시 코드
일관적 (consistent) 동작
x, y 가 null 이 아닌 값이라면, x.equals(y) 는 여러 번 실행하더라도 항상 같은 결과를 반환해야 한다
null 과 관련된 동작
x 가 null 이 아닌 값이라면, x.equals(null) 은 항상 false 를 반환해야 한다

URL 과 관련된 equals 문제

java.net.URL 이 equals 를 굉장히 잘못 설계한 예시이다
동일한 IP 주소로 해석될 때는 true, 아닐때는 false 가 반환된다
네트워크 상태에 따라 결과가 다르게 나온다
문제
동작이 일관되지 않는다
네트워크가 정상이라면 URL 이 동일하고, 문제가 있다면 다르다
일반적으로 equals, hashCode 처리는 빠를 거라 예상되지만, 네트워크 처리라 굉장히 느리다
동일한 IP 주소를 갖는다고, 동일한 콘텐츠를 나타내지는 않는다 (Virtual Hosting)
예시 코드

정리

특별한 이유가 있지 않는이상, 직접 equals 를 구현하는 것은 좋지 않다
만약, 직접 구현해야 한다면 반사적, 대칭적, 연속적, 일관적 동작을 하는지 확인해야 한다
이러한 클래스는 final 로 만드는것이 좋다
상속을 한다면, 서브 클래스에서 equals 가 작동하는 방식을 변경하면 안된다
참고) 데이터 클래스는 언제나 final 이다

6-6. hashCode 의 규약을 지켜라

hashCode 함수는 수많은 컬렉션과 알고리즘에 사용되는 자료 구조인 해시 테이블(hash table) 을 구축할 때 사용된다

해시 테이블

컬렉션에 요소를 빠르게 추가하고, 컬렉션에서 요소를 빠르게 추출해야 하는 문제를 해결하기 위해 set, map 을 사용한다
데이터 중복을 허용하지 않는다
배열 또는 링크드 리스트를 기반으로 만들어진 컬렉션은 요소가 포함되어 있는지 확인하는 성능이 좋지 않다
이를 대체하는 방법이 해시 테이블이다
해시 테이블은 각 요소에 숫자를 할당하는 함수가 필요하다
이를 해시 함수라고 부른다
해시 함수는 같은 요소라면 항상 같은 숫자를 반환한다
해시 함수의 특성
빠르다
충돌이 적다
다른 값이라면 최대한 다른 숫자를 반환해야 한다
코틀린/JVM 에 있는 기본 세트 (LinkedHashSet) 와 기본 맵 (LinkedHashMap) 도 해시 테이블을 사용한다
코틀린은 해시 코드를 만들 때 hashCode 함수를 사용한다

가변성 관련된 문제

요소가 추가될 때만 해시 코드를 계산한다
요소가 변경되어도 해시 코드는 계산하지 않으며, 버킷 재배치도 이루어지지 않는다
기본적인 LinkedHashSet, LinkedHashMap 의 키는 한 번 추가한 요소를 변경할 수 없다
Set, Map의 키로 mutable 요소를 사용하면 안되며 사용하더라도 요소를 변경해서는 안된다
이러한 이유로 immutable 객체를 많이 사용한다
예시 코드

hashCode 의 규약

공식적인 규약 ( 코틀린 1.3.11 기준 )
어떤 객체를 변경하지 않았다면, hashCode 는 여러 번 호출해도 그 결과가 항상 같아야 한다
equals 메서드의 실행 결과로 두 객체가 같다고 나온다면, hashCode 메서드의 호출 결과도 같다고 나와야 한다
hashCode 는 최대한 요소를 넓게 배치해야 한다
다른 요소라면 최대한 다른 해시 값을 갖는 것이 좋다
많은 요소가 같은 버킷에 배치되는 경우가 발생하면 해시 테이블을 쓸 이유 자체가 사라진다
예시 코드

hashCode 구현하기

일반적으로 data 한정자를 붙이면 코틀린이 알아서 적당한 equals, hashCode 를 정의해주기 때문에 직접 정의할 일이 없다
다만, equals 를 따로 정의했으면 hashCode 도 함께 정의해줘야 한다
코틀린 stdlib 에서 해시 함수를 제공하지 않는 이유
일반적으로 직접 구현할 일이 없기 때문
가장 쉽게 사용하려면 data 한정자를 붙이면 된다
예시 코드

6-7. compareTo 의 규약을 지켜라

compareTo 메서드는 Comparable<T> 인터페이스에 들어있다
compareTo 동작
비대칭적 동작
a ≥ b 이고, b ≥ a 이면 a == b 여야 한다
연속적 동작
a ≥ b 이고 b ≥ c 이이면 a ≥ c 여야 한다
코넥스적 동작 (connex relation)
두 요소는 어떤 확실한 관계를 갖고 있어야 한다
a ≥ b 또는 b ≥ a 중 적어도 하나는 항상 true 를 반환해야 한다

compareTo 를 따로 정의해야 할까?

코틀린에서 compareTo 를 정의해야 하는 상황은 거의 없다
일반적으로 어떤 프로퍼티 하나를 기반으로 순서를 지정하는 것으로 충분하기 때문이다
예시 코드
객체 순서를 비교하려면 comparator 를 사용하는 것이 좋으며, 자주 사용하면 companion 객체로 만들어두는것이 좋다
예시 코드

compareTo 구현하기

compareTo 를 구현할 때 유용하게 활용할 수 있는 톱레벨 함수를 활용하면 된다
값을 단순하게 비교하면 compareValues 를 사용한다
예시 코드
더 많은 값을 비교하거나, selector 를 활용해서 비교하고 싶으면 compareValuesBy 를 사용한다
예시 코드

6-8. API 의 필수적이지 않는 부분을 확장 함수로 추출하라

클래스의 메서드를 정의할 때는 메서드를 멤버로 정의할 것인지 확장 함수로 정의할 것인지 결정해야 한다
예시 코드
두 방식 중에 어떤 방식이 우월한것은 없으며, 각각 장단점이 있기 때문에 상황에 맞게 사용하면 된다

멤버 vs 확장 차이점

확장은 따로 가져와서 사용해야 한다
그래서 일반적으로 확장은 다른 패키지에 위치한다
확장은 우리가 직접 멤버를 추가할 수 없는 경우, 데이터와 행위를 분리하도록 설계된 프로젝트에서 사용된다
필드가 있는 프로퍼티는 클래스에 있어야 하지만, 메서드는 클래스의 public API 만 활용하면 어디에 위치해도 상관없다
확장은 같은 타입에 같은 이름으로 여러 개 만들 수 있다
여러 라이브러리에서 여러 메서드를 받을수도 있고, 충돌이 발생하지도 않는다는 장점이 생긴다
하지만, 같은 이름으로 다른 동작을 하는 확장이 있다는것은 위험할 수 있다
확장은 virtual 이 아니다
파생 클래스에서 오버라이드 할 수 없다
확장 함수는 컴파일 시점에 정적으로 선택된다
확장 함수는 가상 멤버 함수와 다르게 동작한다
상속을 목적으로 석ㄹ계된 요소는 확장 함수로 만들면 안된다
예시 코드
확장 함수는 클래스가 아닌 타입에 정의하는 것이다
nullable 또는 구체적인 제네릭 타입에도 확장 함수를 정의할 수 있다
예시 코드
확장은 클래스 레퍼런스에서 멤버로 표시되지 않는다
확장 함수는 어노테이션 프로세서 (annotation processor) 가 따로 처리하지 않는다
필수적이지 않은 요소를 확장 함수로 추출하면, 어노테이션 프로세스로부터 숨겨진다

정리

확장 함수는 읽어 들여야 한다
확장 함수는 virtual 이 아니다
멤버는 높은 우선 순위를 갖는다
확장 함수는 클래스 위가 아니라 타입 위에 만들어 진다
확장 함수는 클래스 레퍼런스에 나오지 않는다
확장 함수는 더 많은 자유와 유연성을 제공해준다
API 의 필수적인 부분은 멤버로 두는 것이 좋지만, 필수적이지 않은 부분은 확장 함수로 만드는것이 좋다

6-9. 멤버 확장 함수의 사용을 피하라

어떤 클래스에 대한 확장 함수를 정의할 때, 이를 멤버로 추가하는 것은 좋지 않다
확장 함수는 첫번재 아규먼트로 리시버를 받는 단순한 일반 함수로 컴파일 된다
DSL 을 만들 때를 제외하면 이를 사용하지 않는것이 좋다
예시 코드
확장 함수를 클래스에 넣으면 가시성을 제한할 수 없다
예시 코드

멤버 확장을 피해야 하는 이유

레퍼런스를 지원하지 않는다
예시 코드
암묵적 접근을 할 때, 두 리시버 중에 어떤 리시버가 선택될지 혼동된다
예시 코드
확장 함수가 외부에 있는 다른 클래스를 리시버로 받았을 때, 해당 함수가 어떤 동작을 하는지 명확하지 않다
예시 코드
경험이 적은 개밪라의 경우 확장 함수를 보면, 직관적이지 않거나 이해하기 어려울 수 있다

정리

멤버 확장 함수를 사용하는 것이 의미가 있는 경우에는 사용해도 괜찮다
하지만 일반적으로는 그 단점을 인지하고, 사용하지 않는 것이 좋다
가시성을 제한하려면, 가시성과 관련된 한정자를 사용하면 된다
클래스 내부에 확장 함수를 배치한다고, 외부에서 해당 함수를 사용하지 못하게 제한되는 것이 아니다