백명석 님의 클린 코더스 강의를 듣고 요약정리한 글입니다. 문제가 있을 경우 삭제 조치하도록 하겠습니다.
1. Temporal Coupling
함수를 호출할 때, 순서를 지켜야 한다. 예를 들어, 아래 예시처럼 실행하는데, 순서가 중요한 경우를 Temporal Coupling 이 있다고 볼 수 있다.
// open, execute, close 예시
//file should be opened before processing
fileCommand.process(file);
//file should be closed after processing
개발자는 순서를 무시하거나, 단계를 스킵할 수 있다. 이를 해결하기 위해서 별도의 클래스로 래핑하여 해결할 수 있다. (Passing a Block)
fileCommanTemplate.process(myFile, new FileCommand(){
public void process(File f) {
//file processing codes here
}
});
class FileCommandTemplate {
public void process(File file, FileCommand command){
file.open();
command.process(file);
file.close();
}
}
2. CQS
1번 강의에서 Command vs Query 파트에서는 Command가 편의상 무언가 반환할 수 있다고 이야기하는데, 어떤 게 맞는 건지 추가 학습해야 한다.
command는 상태 변경을 유발한다. (Side effect를 가진다.) 그리고 아무것도 반환하지 말아야한다. (반환형이 void 타입이어야 한다.) 반면, query는 내부 상태를 변경하지 않고, (Side effect가 없다.) 계산이나 상태 등을 반환한다.
그리고 CQS는 이 둘이 분리되어야 한다는 것을 의미한다. 왜냐하면, 사람들이 각 시그니처를 보고, 어떤 일을 하는지 예측할 것이기 때문이다. (오해를 유발함)
CQS는 Side effect를 관리하는 좋은 방법이다.
CQS를 지키기 위해서 아래 규칙을 지켜야 한다.
- 상태를 변경하는 함수는 값을 반환하면 안된다.
- 값을 반환하는 함수는 상태를 변경하면 안 된다.
아래 예시를 보면, user를 받기 위해서는 매번 login을 해야 한다. 또한, login을 하면 원치 않아도 user 정보를 읽어야 한다. (CQS 위반)
User u = authorizer.login(userName, password);
CQS를 지켜서 코딩하면 아래와 같다.
authorizer.login(userName, password); //void
User u = authorizer.getUser(userName);
당신 코드의 독자들을 혼란스럽게 하지 말아야 한다.(신뢰의 문제) 따라서, 값을 반환하는 함수는 상태를 변경하면 안 된다. 또한 상태를 변경하는 함수는 exception을 발생시킬 수는 있지만, 값을 반환할 수 없다는 약속을 지켜야 한다.
3. Tell Don’t Ask
extream 한 CQS는 C와 Q를 함께 사용하지 않도록 한다. (CQS를 극단적으로 보면 Tell Don’t Ask를 준수하라는 말이 된다.)
if(user.isLoggedId())
user.excute(command);
else
authenticator.promptLogin();
위와 같은 예시를 아래처럼 개선할 수 있다. (커맨드만 존재)
try {
user.excute(command);
} catch(User.notLoggedIn e) {
annunciator.promptLogin();
}
나아가서 아래와 같이 user 객체가 모든 것을 처리하도록 하는 것이 더 나아 보인다. (인자로 던지고 네가 알아서 해)
user.execute(command, annuciator);
로그인 여부를 아는 것은 user 객체가 알아야 한다. 맨 위 예제의 user 객체의 상태를 가져와서 직접 판단하는 행위는 Ask에 해당한다. 이러한 것 대신에 user에게 tell 하는 것이 가장 옳다. (자율적인 객체가 떠올랐음)
Tell other obejct what to do But not to ask object what the state is.
이 규칙을 준수하다 보면 Q가 많이 필요 없어진다. Q가 많이 필요 없어진다는 것은 좋은 것이다. 왜냐하면 Q는 out of control 되는 경향이 있기 때문이다. 즉, 제어하기 상당히 어렵다. (내 상태를 다른 넘한테 막 주면 그 사람이 뭘 할지 모르니깐 제어하기 힘들어짐) 또한 CQS 위반을 파악하고, 준수하도록 계속 개선하다 보면 Tell dont ask를 만족하게 된다.
아래는 명백한 Tell dont ask 위반이다. (Long chain of function, Train wrecks라고도 함) 왜냐하면, 뭔가 tell(doXXX) 하기 전에 지속적으로 ask(getXXX) 해야 하기 때문이다.
o.getX()
.getY()
.getZ()
.doSomething();
위의 예시를 아래처럼 바꿔야 한다.
o.DoSomething();
4. Law of Demeter
위 Long chain of function 사례처럼 하나의 함수가 전체 시스템의 객체들 간의 내비게이션을 아는 것은 잘못된 설계이다. 한 라인의 코드가 알아야 하는 것으로 너무 방대한 지식이며 함수가 시스템에 너무 많은 의존성을 가지게 된다.
- o는 X를 갖는다.
- X는 Y를 갖는다.
- y는 Z를 갖는다.
- Z는 doSomething() 할 수 있다.
Law of Demeter는 한 객체가 알아야 하는 다른 객체를 최소한으로 유지하라는 의미로 최소 지식 원칙이라고도 불린다. 또한 객체 구조의 경로를 따라 멀리 떨어져 있는 낯선 객체에 메시지를 보내는 설계는 피해야 한다. (Don’t Talk to Strangers)
다시 말하자면, 객체는 내부적으로 가지고 있거나, 메시지를 통해 확보한 정보를 이용해 의사결정을 내려야 하고, 다른 객체를 탐색해 뭔가를 일어나게 해서는 안 된다.
아래 규칙을 기억하자!
- 함수가 시스템의 전체를 알게 하면 안 된다.
- 개별 함수는 아주 제한된 지식만 가져야 한다.
- 객체는 요청된 기능 수행을 위해 이웃 객체에게 요청해야 한다.
- 요청을 수신하면 적절한 객체가 수신할 때까지 전파되어야 한다.
Law of Demeter의 공식이 존재한다.
- 아래와 같은 객체들의 메서드만 호출 가능
- 인자로 전달된 객체
- 자기 지역에 생성된 객체
- 필드로 선언된 객체
- 전역 객체
- 이전 메서드 호출의 결과로 얻은 객체의 메서드를 호출하면 안된다.
- o.getX().getY().getZ().doSomething();
ask 대신 tell 하면 주변에 있는 객체들과 decouple 된다.
코드를 클린하고 테스트 가능하게 만들려면, 의존성이 줄줄이 엮어 들어가는 것을 막아야 한다. 이를 위해서 CQS, Tell, Don’t ask, Law of Demeter를 준수해야 한다는 것이 결론이다.
5. Early returns
private boolean validate() {
if(name.equals(""))
return true;
if(!WikiWordWidget.xxx)
return true;
return false;
}
위와 같이 리턴은 최대한 빠르게 하는 것이 좋다. 루프 중간에서 리턴하는 것은 문제이다. 코드가 동작하도록 하는 것보다 이해할 수 있게 하는 것이 더 중요하다.
6. Error handling
에러 처리를 위해 pop은 null을 반환하고, push는 false를 반환할 수 있다. 이 보다는 exception을 발생시키는 것이 좋다. 그리고 exception의 이름은 최대한 구체적이어야 한다. 예를 들면, Stack.Overflow처럼 말이다.
public static Stack make(int capacity) {
if(capacity < 0)
throw new IllegalCapacity();
if(capacity == 0)
return new ZeroCapacityStack();
return new BoundedStack(capacity);
}
public static class IllegalCapacity extends RuntimeException {}
그리고 checked 예외랑 unchecked 예외의 차이를 잘 알아야 한다. checked 예외는 reverse dependency를 유발한다.
예를 들면, 하위 클래스에서 어떤 exception이 유발되면 상위 클래스를 변경해야 하고, 사용되는 모든 코드를 변경해야 한다. checked exception을 던지는 사람은 편하다, 하지만 그 메서드를 사용하는 모든 사람들은 try catch를 해야 한다. (아예 쓰지마.. 라고한다..) 하지만, catch를 해서 명확하게 할 일이 있거나, 다른 사람을 못 믿을 경우(public api 일 경우) checked exception을 사용할 수 있다.
그리고 exception 메시지는 필요 없는 것이 가장 좋다. exception 이름을 정확하게 지어 이름으로 의미가 전달되도록 해야 한다고 한다. 그리고 제일 나쁜 것은 exception 클래스 자체에 메시지를 가지고 있는 것이다. exception resolver나 message handler를 사용하는 것이 좋다.
단, exception resolver가 사용할 정보(플레이스 홀더를 채울 놈들)들은 exception 클래스의 필드로 존재해야 한다.
가장 좋은 코멘트는 코멘트를 작성하지 않는 거이다. 코드(이름)가 커멘트를 대신하도록 해라!
7. Special Cases
위 스택 예제를 다시 생각해보자. 만약, capacity를 0으로 stack을 생성하면 어떻게 되는가? 새로운 exception을 추가해야 하는가? 아니다. 너무 많은 exception을 생성하는 것을 원하지 않는다. 게다가 capacity 가 0인 스택이 오류인지 의미가 있는 건지 알아야 한다.
사이즈가 0인 스택은 잘 정의된 행위를 가진다.
- push를 호출하면 Overflow
- pop을 호출하면 Underflow
- getSize는 0을 반환
이런 기능 구현을 스택 클래스의 모든 메서드에 size가 0인지 비교하는 로직을 추가할 수 있다. 하지만 이 보다 더 좋은 방법인 Null Object Pattern (Special Case Pattern)이 있다.
8. Null is not an error
stack이 비어있을 때 top 함수는 무엇을 반환해야 하는지 떠올려보자. null을 반환할 수도 있다. 하지만, top을 호출하는 그 누구도 null을 기대하지는 않을 것이다. null은 NPE를 발생시킬 때까지 시스템의 여기저기에 조용히 퍼지는 속성이 있다. 따라서 StackEmpty exception을 발생시키는 것이 좋다.
public Integer top() {
if(size == 0)
throw new Empty();
return elements[size - 1];
}
@Test(expected = Stack.Empty.class)
public void whenStackIsEmpty() {
stack.pop();
}
9. Null is a value
만약 스택의 find가 적절한 element를 찾지 못하면 어떻게 해야 하는지 떠올려보자. 만약 요소가 없을 경우, exception을 던진다면, 부적절할 수 있다. exception은 기대하지 못한 때를 위한 것이며 어떨 때는 find가 실패할 것을 기대하기 때문이다. 이럴 때, 발견되지 않을 때 반환할 수 있는 값이 필요하다.
indexOf가 발견하지 못하는 경우 -1을 반환하는데, -1은 별도의 의미를 갖는다.(Nothing) 하지만, -1은 Nothing이 아니다. 따라서 Nothing을 의미하는 null을 반환하는 것이 나을 듯하다. (명석님이 억측스러운 부분이 있다고 함)
public Integer find(int element) {
for(int i = size - 1; i >=0; i--) {
if(elements[i] == element) {
return size - 1 - i;
}
}
return -1;
}
//refactoring
public Integer find(int element) {
for(int i = size - 1; i >=0; i--) {
if(elements[i] == element) {
return size - 1 - i;
}
}
return null;
}
10. try도 하나의 역할/기능이다.
- 함수 내부에 try 문장이 있다면 try 문장이 변수 선언을 제외하고는 첫 번째 문장이어야 한다.
- try 블록 내에는 한 문장(함수 호출)만 있어야 한다.
- finally가 함수의 마지막 블록이어야 한다. 이후에 어떤 라인도 없어야 한다.
- 함수는 하나의 일만 해야 한다. 그리고 error handling은 하나의 일이다.