[엘레강트 오브젝트] 4-3장 final이나 abstract이거나
이 글은 엘레강트 오브젝트 새로운 관점에서 바라본 객체지향 도서를 보며 스터디한 글입니다.
책에서 주장하는 내용을 정리하였으며 예제들은 모두 코틀린 코드로 변환하여 작성하였습니다.
목차
- 잘못된 상속
- 클래스의 신분
- 올바른 설계
- 결론
1. 잘못된 상속
상속은 매우 강력한 기능입니다. 하지만 상속을 잘못 사용한다면 문제를 일으킬 수 있습니다. 문제를 일으키는 원인은 가상 메서드입니다.
코드로 살펴보겠습니다.
open class Document {
fun length(): Int {
return content().length
}
open fun content(): String {
// read document
// load byte array
return ""
}
}
위 코드와 같은 Document 클래스가 있습니다.
- content(): Document의 내용을 읽어와 반환합니다.
- length(): content 메서드의 내용의 길이를 반환합니다.
그렇다면 Document의 내용을 인코딩하여 반환하는 클래스인 EncryptedDocument를 생성해보겠습니다. 이 클래스는 위 클래스를 상속받아 content 메서드를 Override하여 작성해보겠습니다.
class EncryptedDocument: Document() {
override fun content(): String {
// read document
// load byte array
// encrypt document
return ""
}
}
위 클래스의 length 메서드를 사용하면 어떤 값이 반환될까요??
val length = EncryptedDocument().length()
length() 메서드는 content() 메서드의 내용의 길이를 반환하는 메서드입니다. 하지만 EncryptedDocument 클래스에서 content 메서드를 Override하여 재구성했기 때문에 length() 메서드에서 반환하는 값도 바뀔 것입니다.
length()
기대한 값: Document의 길이
실 결과값: Document를 인코딩한 길이
상속은 자식 클래스가 부모 클래스의 코드를 계승받는 하향식 프로세스입니다. 하지만 위 코드는 자식이 부모의 기능에 접근하여 영향을 미치는 구조로 작성되었습니다. 이런 문제는 유지보수할 때 큰 위험이 될 수 있습니다.
이러한 잘못된 설계를 막는 방법에는 final이나 abstract를 사용하여 막을 수 있습니다.
2. 클래스의 신분
클래스는 final, abstract, 앞의 두 경우가 아닌 경우로 3가지 신분을 가지고 있습니다.
final
final 클래스는 사용자 관점에서 블랙 박스에 해당합니다. final 클래스는 상속이 불가능하고 이 클래스만으로 역할을 수행할 수 있는 능력을 가지고 있는 클래스입니다.
abstract
abstract 클래스는 글래스 박스에 해당합니다. 불완전한 클래스로 스스로 행동할 수 없고 상속받는 클래스의 도움을 필요로 합니다. abstract 클래스는 특정 메서드를 abstract 처리해야하며 상속받는 클래스는 이 메서드를 반드시 구현해야만 합니다.
앞의 두 경우가 아닌 경우
이 클래스는 블랙 박스, 글래스 박스 두 경우 모두 해당하지 않습니다. 모든 메서드를 오버라이드할 수 있고 자신 혼자서 모든 역할을 수행할 능력도 갖추고 있습니다. 그렇기 때문에 개발자는 이 클래스를 사용하는 데에 있어 혼란스러울 수밖에 없습니다. 어떤 목적성을 가지고 구현된 클래스인지 모르기 때문입니다.
3. 올바른 설계
위에서 작성한 Document에 대한 설계는 interface, abstract 를 이용한 2가지 방법으로 개선할 수 있습니다.
Interface
// Document interface
interface Document {
fun length(): Int
fun content(): String
}
// Document 인터페이스를 구현하는 DefaultDocument
// kotlin은 default가 final
class DefaultDocument: Document {
override fun length(): Int {
// code 동일
}
override fun content(): String {
// code 동일
}
}
// Document 인터페이스를 구현하는 EncryptedDocument
class EncryptedDocument(
private val plain: Document
): Document {
override fun length(): Int {
return this.plain.length()
}
override fun content(): String {
val content = this.plain.content()
return encrypt(content)
}
}
Document 인터페이스를 통해 length(), content() 메서드를 오버라이드하여 구현하는 방법입니다.
Document 인터페이스를 구현하는 DefaultDocument 클래스는 final 처리되어있기 때문에 상속이 불가능합니다. Document를 암호화하는 클래스를 만들려면 새로운 클래스를 통해 Document를 구현하는 방식으로 설계할 수 밖에 없습니다. 이렇게 개발자에게 문제가 발생할 가능성을 닫아두게 설계할 수 있습니다.
abstract
// Document 추상 클래스
abstract class Document {
abstract fun content(): String
fun length(): Int {
return this.content().length
}
}
class DefaultDocument: Document() {
override fun content(): String {
// read document
}
}
class EncryptedDocument: Document() {
override fun content(): String {
// read document
// encrypt document
}
}
Document 추상클래스를 이용하여 설계한 코드입니다.
이 방법은 위에서 설계상 혼란을 줄 수 있는 코드와 비슷하게 느껴질 것입니다. 당연하게도 결과는 동일할 것입니다. 하지만 차이점은 Document가 추상클래스로 content() 메서드를 구현을 필수적으로 선언했다는 차이가 있습니다. 이를 통해 개발자는 length() 메서드가 어떻게 동작할지 미리 인지하고 코드를 작성하게 될 것입니다.
4. 결론
클래스는 명확하게 어떤 의도로 설계된 것인지 구분해야합니다. 명확한 설계가 없다면 상속을 사용하지 않는 것이 나을 수 있습니다.