본문 바로가기
개발/Java, Kotlin

[Effective Kotlin] Item 31: Define contract with documentation

by 달사쿠 2021. 9. 11.
반응형

Intro

문서로 약속을 정의해라.

 

Item 27: Use abstraction to protect code against change 에서 나왔던 '메세지를 보여주는 함수'에 대해서 다시 보겠습니다.

fun Context.showMessage(
    message: String, 
    length: MessageLengh = MessageLength.LONG
) {
    val toastLength = when(length) {
        SHORT -> Toast.LENGTH_SHORT
        LONG -> Toast.LENGTH_LONG
    }
    Toast.makeText(this, message, toastLength).show()
}

enum class MessageLength { SHORT, LONG }

 

위 코드는 어떻게 메세지를 보여줄지와 관련된 내용을 추출했습니다.

문서화가 되어있지 않기때문에, 누군가는 이 코드를 보고 항상 알림을 표시한다고 가정할 수 있습니다.

 

명확하게 하려면 이 함수에서 예상되는 내용을 설명하는 의미있는 KDoc 주석을 추가하는 것이 좋습니다.

/**
* 프로젝트가 사용자에게 짧은 메시지를 표시하는 보편적인 방법
* @param message 유저에게 보여질 텍스트
* @param length 얼마나 길게 메시지를 보여줄지
*/
fun Context.showMessage(
    message: String, 
    length: MessageLength = MessageLength.LONG
) {
    val toastLength = when(length) {
        SHORT -> Toast.LENGTH_SHORT
        LONG -> Toast.LENGTH_LONG
    }
    Toast.makeText(this, message, toastLength).show()
}

enum class MessageLength { SHORT, LONG }

 

많은 경우에는, 이름으로 전혀 유추되지 않는 내용들도 있습니다.

아래의 예시는 powerset(멱집합)는 잘 정의된 수학적 개념이지만, 잘 알려져 있지 않고 해석이 충분히 명확하지 않기 때문에 설명이 필요합니다.

Powerset(멱집합)이란?
주어진 집합의 모든 부분 집합들로 구성된 집합으로,
{a, b, c}의 멱집합은 {Φ}, {a}, {b}, {c}, {a, b}, {a, c}, {b, c}, {a, b, c} 입니다.
/**
* Powerset returns a set of all subsets of the receiver including itself and the empty set
* Powerset(멱집합)은 빈 set이나 자기 자신을 포함한 수신자의 모든 subset의 set을 리턴한다.
**/
fun <T> Collection<T>.powerset(): Set<Set<T>> = 
    if(isEmpty()) setOf(emptySet())
    else take(size - 1)
        .powerset()
        .let { it + it.map { it + last() } }
        
/**
* take : take는 얼마만큼의 값을 앞에서부터 취할지를 n 이라는 인수로 받습니다. 
* Collection의 앞에서부터 n만큼만 취해 List를 반환합니다.
**/

 

예제의 설명은 원소의 순서에 대한 설명이 없어서 우리에게 자유를 줍니다.사용자는 이런 원소의 순서에 의존하면 안되기 때문에, 추상화 뒤에 구현을 숨겨 이 함수가 외부에서 어떻게 보이는지 변경하지 않고 최적화가 가능합니다.(책에 있는 예제를 가져오긴했는데,, 원소의 순서랑 무슨 상관이 있는지는 잘 모르겠다. 다만 추상화를 했기 때문에 그냥 description자체를 그대로 받아들이고 사용할 수 있다는 것을 말하고 싶은 것,,,같은데,,,모르겠다!)

/**
* Powerset returns a set of all subsets of the receiver including itself and the empty set
* Powerset(멱집합)은 빈 set이나 자기 자신을 포함한 수신자의 모든 subset의 set을 리턴한다.
**/
fun <T> Collection<T>.powerset(): Set<Set<T>> = 
    powerset(this, setOf(setOf())

private tailrec fun <T> powerset(
    left: Collection<T>,
    acc: Set<Set<T>>
): Set<Set<T>> = when {
    left.isEmpty() -> acc
    else -> {
        val head = left.first()
        val tail = left.drop(1)	//left의 첫번째 원소 제거
        powerset(tail, acc + acc.map { it + head })
    }
}
cf. tailrec (꼬리재귀)
추가적인 연산이 없이 자신 스스로 재귀적으로 호출하다가 어떤 값을 리턴하는 함수로,
코틀린 자체적으로 loop문으로 변환해주어서 재귀를 그냥 사용했을 때의 콜스택보다 자원을 덜 사용하게 변경해줍니다.

 

일반적인 문제로 함수의 동작이 문서화되어있지 않고, 원소의 이름이 명확하지 않은 경우, 개발자는 추성화 대신 구현 자체에 의존합니다. 이런 문제를 함수의 예상되는 동작을 설명함으로써 해결할 수 있습니다.

 


Contract (계약)

행동을 설명하면, 사용자는 약속으로 간주하고 기대치를 예상합니다. 이런 모든 예상동작을 contract of an element(요소의 계약?)이라고 합니다.

계약(Contract)을 정의하는 것이 뭔가 무섭게 들릴 수 있지만, 계약을 잘 정의하면 작성자와 사용자 모두에게 좋습니다.

  • 작성자
    • 클래스가 사용되는 방식에 대해 걱정할 필요가 없어짐
    • 계약이 충족되는 한 모든 것을 변경할 수 있는 자유를 준다
  • 사용자
    • 내부에서 무언가가 구현되는 방식에 대해 걱정할 필요가 없어짐
    • 즉, 실제 구현에 대해서 신경 꺼도 된다

작성자와 사용자 모두 계약에 정의된 추상화에 의존하므로 독립적으로 작업할 수 있고,

계약이 존중되는 한 양쪽 모두에게 편안함과 자유를 줍니다.

 


Defining a contract

계약을 정의할 수 있는 방법

  • Names(이름): 이름을 일반적인 개념과 연결지어 정의하면, 개념과 이름이 일치할 것으로 기대하게 됩니다. 예를 들어, sum 메서드는 어떻게 동작할지를 알기 위해 주석을 읽을 필요가 없습니다.
  • Comments and documentation(주석과 문서): 필요한 모든 것을 설명할 수 있는 가장 강려크한 방법
  • Types(타입): 타입은 객체에 대해서 많은 것을 말해줍니다. 함수를 볼 때 반환 유형 및 인수 유형에 대한 정보는 매우 의미가 있습니다.

 


Do we need comments?

주석과 관련해서 커뮤니티의 의견에 대한 역사를 살펴봅시다.

  • Java 초기:  literate programming이 대중적인 개념이라, 코맨트로 모든 설명을 할 것을 제안
  • 10년 뒤: 주석을 생략하고 읽을 수 있는 코드를 작성하는데 집중해야함
    (Robert C. Martin의 Clean Code가 영향력이 있던 것으로 보여짐)

이 책의 필자는, 읽을 수 있는 코드를 작성하는 데 집중해야한다는 것에 전적으로 동의한다고 합니다.

다만 이해해야 할 것은 아래와 같은 부분인 것 같습니다.

  • element(함수 또는 클래스) 앞에 주석이 상위 수준에서 설명하고 계약을 설정할 수 있다는 것
  • 주석은 이제 도큐먼트를 자동으로 생성하는데 자주 사용하며 일반적으로 프로젝트에서 정보의 출처로 사용
  • 명백한 주석은 오히려 노이즈이며,
    함수이름과 매개변수을 통해 elements가 명확하게 표현되는 것은 주석으로 작성하지 마세요~ (불필요한 주석은 쓰지말라는 것)

이런 점과 더불어 클린 코드 관점에서, 구현된 부분을 코멘트로 작성하는 것이 함수 명으로 추출되어야 한다는 것도 동의한다고 합니다. 즉, 기능단위로 추상화를 해라라는 것입니다. (Item 26참고) 아래의 예시와 같은 경우가 리팩토링이 필요해 보이네여

fun update() {
    //Update users
    for (user in users) {
        user.update()
    }
    
    //Update boos
    for (book in boos) {
        updateBook(book)
    }
}

리펙토링 하면, private한 updateBooks()와 updateUsers()로 나누어서, update 메소드에 넣을 수 있겠죠.

 

코멘트는 그래도 종종 유용하고 중요합니다. 예제를 찾고싶다면, Kotlin의 표준 라이브러리의 public function들을 살펴보세요.

Kotlin은 많은 자유를 주는 잘 정의된 계약을 갖고 있습니다. (Kotlin자랑스,,,)

 


KDoc format

KDoc 이란?

주석을 사용하여 기능을 문서화할 때, 해당 주석을 표시하는 공식적인 형식

 

KDoc 주석의 구조

  • 모든 KDoc 주석은 /**로 시작하고 */로 끝나며, 내부적으로 모든 줄은 일반적으로 *로 시작합니다. 
  • 첫 번째 단락: element에 대한 요약 설명
  • 두번째 단락: 자세한 설명
  • 모든 다음줄은 태그로 시작하며, 태그는 element를 설명하기 우해 참조하는데 사용

 

사용되는 태그

  • @param <name>: 함수의 value 파라미터, 클래스/속성/함수의 type 파라미터를 문서화
  • @return: 함수의 반환값을 문서화
  • @constructor: 클래스의 기본 생성자를 문서화
  • @receiver: 확장 함수의 receiver를 문서화
  • @property <name>: 지정된 이름을 가진 클래스의 속성을 문서화하며, 기본 생성자에 정의된 속성에 사용
  • @throws <class>, @exception <class>: 메서드에서 던질 수 있는 예외 문서화
  • @sample <identifier>: 현재 element에 대한 문서에 지정된 qualified 이름을 가진 함수의 body를 포함 (element가 어떻게 사용될 수 있는지에 대해 예를 보여주기 위해)
  • @see <identifier>: 지정된 클래스/메서드에 대한 링크를 추가
  • @author: 문서화되는 요소의 작성자를 지정
  • @since: 문서화되는 유소가 도입된 소프트웨어 버전을 지정
  • @suppress: 생성된 문서에서 element를 제외할 때 사용. 모듈의 공식 API에 속하진 않지만, 여전히 외부에서 볼 수 있어야하는 요소에 사용

KDoc을 사용할 땐 모든 것을 설명할 필요는 없습니다. 가장 좋은 문서는 짧고 명확하지 않을 수 있는 부분을 설명하는 것입니다.

 


Type system and expectations

Type 계층은 객체에 대한 중요한 정보 소스입니다.

Interface는 구현하기로 약속한 method 목록 그 이상이며, Class와 Interface는 기대 이상입니다.

 

클래스가 어떤 기대치를 약속하면, 모든 subclass또한 이를 보장해야합니다. (*리스코프 치환 원칙)

리스코프 치환 원칙
서브 타입은 언제나 자신의 기반타입으로 교체할 수 있어야 한다. [예) 뽀로로 = new 펭귄() <-> 뽀로로 = new 동물()]

즉, 하위 클래스의 인스턴스는 상위형 객체 참조변수에 대입해 상위 클래스의 인스턴스 역할을 하는데 문제가 없어야 한다.
S가 T의 subType (S ⊂ T)이라면, 프로그램의 원하는 속성을 변경하지 않고 typt T의 객체를 type S로 대체할 수 있다.

프로그래밍에서 자식은 항상 부모의 계약을 만족시켜야 합니다.

이 규칙의 중요한 의미 중 하나는 open function에 대한 계약을 적절하게 정하고 작성해야한다는 것입니다.

 

아래의 Car interface 예시를 들어보면, 이 인터페이스에 Document가 없다면 많은 질문을 남깁니다.

(도큐먼트를 빼고 생각해볼게요!)

interface Car {
    /**
    * 차의 방향을 바꾸는 함수
    *
    * @param angle 자동차 wheel을 기준으로 바퀴의 위치를 radian단위로 나타냅니다.
    * 0은 직진, pi/2는 최대한 오른쪽으로, -pi/2는 최대한 왼쪽으로 운전함을 의미합니다.
    * 값은 (-pi/2, pi/2)여야 합니다.
    */
    fun setWheelPosition(angle: Float)
    
    /** 
    * 차량 속도를 0까지 감속  
    *
    * @param pressure ...
    */
    fun setBrakePedal(pressure: Double)
    
    /**
    * 유저에 의해 차량의 속도를 max speed까지 가속할 수 있음
    *
    * @param pressure ...
    */
    fun setGasPedal(pressure: Double)
}

class GasolineCar: Car {
    // ...
}

class GasCar: Car {
    // ...
}

class ElectricCar: Car {
    // ...
}

setWheelPosition함수에서 angle은 무엇을 의미하며, 어떤 단위로 측정될까,, 

gas와 brake pedal의 역할이 뭘까,,

등과 같이 명확하지 않습니다.

 

Car type의 인스턴스를 사용하는 사람들은 사용방법을 알아야하고, 모든 브랜드는 Car로 사용될 때 유사하게 작동해야 합니다.

Document를 작성하면 이런 문제를 해결할 수 있습니다.

 


Leaking implementation

구현의 세부정보는 항상 노출됩니다.

  • 차로 예시를 들자면, 다양한 종류의 엔진이 약간 다르게 작동하기때문에 차마다 차이가 있음.
    계약서에 기재되어 있지 않으니 괜찮습니다. (???!)

프로그래밍 언어에서도 구현 세부정보가 노출됩니다.

  • 예를들어, reflection을 사용하여 함수를 호출하면, 컴파일러에서 최적화하지 않는 한 작동은하지만 일반 함수 호출보다 훨씬 느립니다.
    언어는 약속한 대로 작동하는 한 모든 것이 정상이고, 우리는 적용하기만 하면 됩니다.
Reflection
객체를 통해 클래스의 정보를 분석해 내는 프로그램 기법으로, 아래와 같은 정보를 알 수 있다.
- ClassName
- Class Modifiers
- Package Info
- Superclass
- Implemented Interfaces
- Constructors
- MethodsFields
- Annotations
출처: https://lee-mandu.tistory.com/382 

추상화에서도 구현이 노출되지만,
"개발자가 허용하는 대로 할 수 있고 그 이상은 할 수 없습니다"라고 설명할 수 있는 캡슐화를 통해 가능한 한 보호해야 합니다.

캡슐화 된 클래스와 함수가 많을수록 구현에 의존하는 방식에 대해 생각할 필요가 없기 때문에 내부에 더 많은 자유가 있습니다.

반응형

댓글