이펙티브 코틀린(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 == b 는 a.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 을 만들 때를 제외하면 이를 사용하지 않는것이 좋다
예시 코드
•
확장 함수를 클래스에 넣으면 가시성을 제한할 수 없다
예시 코드
멤버 확장을 피해야 하는 이유
•
레퍼런스를 지원하지 않는다
예시 코드
•
암묵적 접근을 할 때, 두 리시버 중에 어떤 리시버가 선택될지 혼동된다
예시 코드
•
확장 함수가 외부에 있는 다른 클래스를 리시버로 받았을 때, 해당 함수가 어떤 동작을 하는지 명확하지 않다
예시 코드
•
경험이 적은 개밪라의 경우 확장 함수를 보면, 직관적이지 않거나 이해하기 어려울 수 있다
정리
•
멤버 확장 함수를 사용하는 것이 의미가 있는 경우에는 사용해도 괜찮다
◦
하지만 일반적으로는 그 단점을 인지하고, 사용하지 않는 것이 좋다
•
가시성을 제한하려면, 가시성과 관련된 한정자를 사용하면 된다
•
클래스 내부에 확장 함수를 배치한다고, 외부에서 해당 함수를 사용하지 못하게 제한되는 것이 아니다