Study/object

[엘레강트 오브젝트] 1-3장 생성자에 코드를 넣지 마세요

에디개발자 2021. 8. 11. 06:30
반응형

나를 닮았다고 한다...

이 글은 엘레강트 오브젝트 새로운 관점에서 바라본 객체지향 도서를 보며 스터디한 글입니다.

책에서 주장하는 내용을 정리하였으며 예제들은 모두 코틀린 코드로 변환하여 작성하였습니다.

 

토론하기

 

인자에 손대지 말라

인자에 손대는 클래스

먼저 인자에 손대는(?) 코드를 살펴보겠습니다.

class Cash(
    private val dollars: Int
) {
    constructor(dollars: String): this(Integer.parseInt(dollars))
}

 

객체 초기화에는 코드가 없어야하고 인자를 건드리면 안됩니다. 필요하다면 인자를 다른 타입의 객체로 감싸거나 가공하지 않은 형식으로 캡슐화해야합니다. 위 코드를 수정한 예시를 살펴보겠습니다. 

 

인자에 손대지 않는 클래스

class Cash(
    private val dollars: Number
) {
    // String to Integer Constructor
    constructor(dollars: String): this(StringAsInteger(dollars))
    
    fun cents(): Int {
        // cents로 변환 로직
        return 0
    }
}

class StringAsInteger(
    private val source: String
): Number() {
    override fun toInt(): Int {
        return Integer.parseInt(this.source)
    }
}

 

이 클래스는 실제로 사용하는 시점까지 객체의 변환 작업을 연기합니다. 

 

두 Cash 클래스는 표면적으로는 생성하는 과정은 동일합니다.

// Cash Class 생성
val five = Cash("5")

차이는 캡슐화하는 대상입니다. 첫 번째는 숫자 5를 캡슐화하고 두 번째는 Number처럼 보이는 StringAsInteger 인스턴스를 캡슐화합니다. 

 

 

진정한 객체지향에서의 인스턴스화란?

작은 객체들을 조합해서 큰 객체를 만드는 것을 의미한다.
객체들을 조합해야 하는 단 하나의 이유는 새로운 계약을 준수하는 새로운 엔티디가 필요하기 때문이다.

 

텍스트 객체 "5"를 사용하는 방식에는 어떤 문제가 있을까?

Cash 클래스의 객체를 생성해야 했던 이유는 무엇일까?

텍스트 객체에 String 타입을 직접 사용할 수 없었던 이유는 무엇일까? 

 

정답은 텍스트 타입의 객체가 필요한 메서드를 제공하지 않았기 때문입니다. 앞의 에제에서 텍스트 객체는 필요로 하는 계약을 따르지 않습니다. 이 문제를 해결하기 위해서는 Cash 타입의 five 객체를 만들어야합니다. five 객체는 더 이상 String 계약을 따르지 않고 다른 계약에 따라 동작합니다.

 

책의 내용 중 누락된 부분이 있어 cents() 메서드를 추가하고 부가설명을 작성합니다.

"5" 텍스트 타입은 cents() 메서드를 사용할 수 없습니다. "5" 텍스트 타입을 Cash클래스로 생성하여 cents() 메서드를 사용할 수 있도록 합니다.

 

생성자 내부는 항상 비어있어야 한다.

생성자 내부가 비어있어야 하는 이유는 기술적인 이유가 존재합니다. 

 

첫 번째 이유는 생성자에 코드가 없을 경우 성능 최적화가 더 쉽기 때문에 코드의 실행 속도가 빨라집니다. 

생성자에 코드가 없는 클래스

class StringAsInteger(
    private val source: String
): Number() {
    // 메서드에서 파싱
    override fun toInt(): Int {
        return Integer.parseInt(this.source)
    }
}

// Use
val num = StringAsInteger("123")
num.toInt()  // first parse
num.toInt()  // second parse
num.toInt()  // third parse

이 코드는 toInt()를 호출할 때마다 텍스트를 정수로 파싱하고 있습니다. 

 

생성자에 코드가 있는 클래스

class StringAsInteger(
    private val num: Int
): Number() {
    // 생성자에서 파싱
    constructor(txt: String): this(Integer.parseInt(txt))
    
    override fun toInt(): Int {
        return num
    }
}

// Use
val num = StringAsInteger("123")  // first parse
num.toInt()  // Nothing
num.toInt()  // Nothing
num.toInt()  // Nothing

얼핏보면 이 코드가 더 효율적으로 보일 수 있습니다. 하지만 생성자에서 직접 파싱을 수행하면 최적화가 불가능해집니다. 객체를 만들때마다 매번 파싱이 수행되기 때문에 실행 여부를 제어할 수 없습니다. 즉 toInt()를 호출할 필요가 없는 경우에도 파싱을 실행합니다.

 

val num = StringAsInteger("123")  // parse 수행
if (잘못된 경우) {
    throw Exception("문제 발생!!")  // 문제가 발생하여 throw
}

num.toInt()  // 실행하지 못함

위 코드를 살펴보면 toInt() 메서드가 호출되지 않았음에도 parse가 불필요하게 실행된 것을 확인할 수 있습니다. 이런 문제를 해결하기 위해 toInt() 메서드에 파싱하는 작업을 수행하도록 합니다. 

 

 

그럼 toInt() 메서드를 호출할 때마다 파싱을 수행하는데 비효율적이지 않을까?

데코레이터를 추가해서 최초의 파싱을 캐싱할 수 있습니다.

class CacheNumber(
    private val origin: Number
): Number() {
    // cache
    private val cached = mutableListOf<Int>()

    override fun toInt(): Int {
        if (this.cached.isEmpty()) 
            this.cached.add(origin.toInt())
        
        return this.cached[0]
    }
}

 이 책에서 cached를 Reference Integer를 사용하지 않고 list를 사용한 이유는 null을 피하기 위해서라고 합니다.

코틀린에서 Null은 안전하게 잡아주는데 왜 그렇게 해야.. 이 책에서 말하는 내용에만 집중하자!

 

이와 같은 해결방법은 제어하기 쉽고 투명하다는 점입니다. 객체를 인스턴스화하는 동안에는 객체를 만드는 일 이외에는 어떤 일도 수행하지 않습니다. 실제 작업은 메서드가 수행합니다. 더불어 우리가 직접 이 과정을 제어할 수 있습니다.

 

생성자에서 코드를 없애면 사용자가 쉽게 제어할 수 있는 투명한 객체를 만들 수 있으며, 객체를 이해하고 재사용하기 쉬워집니다. 생성자에서 로직이 들어가면 나중에 리팩토링하기가 어려워집니다. 리팩토링을 수행하는 프로그래머는 생성자 내부의 로직을 메서드로 옮기고 나서야 코드를 실제로 변경할 수 있습니다. 

 


주관적인 생각

생성자에 로직을 넣었을 때 불필요한 리소스를 낭비하는 것에 대해서는 동의합니다. 하지만 언어별로 다를 수 있다고 생각합니다. 코틀린에서는 델리게이션을 사용하여 lazy 설정을 할 수 있습니다. 생성자에서 간단한 로직을 처리한 후 메서드에서 처리한 값을 리턴해준다면 캐싱처리도 따로 하지 않고 간편하게 쓸 수 있지 않을까? 라는 생각을 해봅니다. 

 

Kotlin 델리게이션을 사용한 lazy 설정

// lazy 대상 클래스
class LazyClass(private val number: Int) {
    constructor(text: String): this(Integer.parseInt(text)) {
        println("Call Constructor")
    }

    fun toInt(): Int {
        println("Call toInt!")
        return number
    }
}
class LazyClassTest {
    @Test
    fun `lazy class test`() {
        // test 변수에 lazy 를 이용하여 LazyClass를 선언
        val test by lazy { LazyClass("111") }
        
        // 실행 시점을 알기위해 임시 print
        println("Create LazyClass")
        
        // toInt() 사용
        test.toInt()
        test.toInt()
        test.toInt()
    }
}

 

결과는 위 글에서 캐싱처리한 결과와 동일합니다.

Create LazyClass    // 실행 시점을 알기위해 임시 print 
Call Constructor    // 클래스 생성
Call toInt!         // toInt() 메서드 실행
Call toInt!
Call toInt!

 

이 책의 내용이나 제 생각과 다른 의견이 있으시면 댓글을 달아주세요. :)
반응형