— 함수를 만드는 규칙
작게 만들기
- 함수는 최대한 작게 만들자!
- 그렇다면 얼마나 작아야 할까?
블록과 들여쓰기
- if 문/ else 문/ while 문 등에 들어가는 블록은 한 줄이어야 함
→ 바깥을 감싸는 함수 enclosing function 가 작아지며
→ 블록 내에 들어가는 함수 이름을 적절히 짓는다면, 코드를 이해하기가 쉬워짐
- 이 것은 중첩 구조가 생길만큼 함수가 커져서는 안 된다는 의미
→ 들여쓰기 수준은 1단이나 2단을 넘어서지 않게 작성하기
한 가지만 하기
- 함수는 한 가지를 해야 한다. 그 한 가지를 잘 해야 한다. 그 한 가지만을 해야 한다.
- 목록 3-3(p. 43) 을 보면 세 가지 작업을 한다고 할 수도 있지 않은가?
- 하지만 지정된 함수 이름 아래에서 추상화 수준이 하나인 단계만 수행한다면 그 함수는 한 가지 작업만 한다고 할 수 있음
💡 추상화 수준이 하나인 단계라는 것은 정확히 어떤 의미인걸까
- 함수가 ‘한 가지’만 하는지 판단하는 또 다른 방법
- 의미 있는 이름으로 다른 함수를 추출할 수 있다면 그 함수는 여러 작업을 하고 있는 것!
함수 내 섹션
- 목록 4-7(p. 90) 의 generatePrimes 함수는 세 섹션으로 나뉨
→ 한 함수에서 여러 작업을 한다는 의미
- 한 가지 작업만 하는 함수는 자연스럽게 섹션으로 나누기 어려움
함수 당 추상화 수준은 하나로
- 함수가 확실히 ‘한 가지’ 작업만 하려면 함수 내 모든 문장의 추상화 수준이 동일해야 함
// 목록 3-1(p. 40)
// 추상화 수준 높음
getHtml()
// 추상화 수준 중간
String pagePathName = PathParser.render(pagepath)
// 추상화 수준 아주 낮음
.append(“\n”)
- 한 함수 내에 추상화 수준을 섞으면 코드를 읽는 사람이 헷갈림
→ 특정 표현이 근본 개념인지 세부사항인지 구분하기 어렵기 때문
- 근본 개념과 세부사항을 뒤섞기 시작하면, 깨어진 창문처럼 사람들이 함수에 세부사항을 점점 더 추가함
위에서 아래로 코드 읽기: 내려가기 규칙
- 코드는 위에서 아래로 이야기처럼 읽혀야 좋음
- 한 함수 다음에는 추상화 수준이 한 단계 낮은 함수가 옴
→ 즉, 위에서 아래로 프로그램을 읽으면 함수 추상화 수준이 한 번에 한 단계씩 낮아짐
- 추상화 수준이 하나인 함수를 구현하는 것은 어렵지만
- 위에서 아래로 TO 문단을 읽어내려 가듯 코드를 구현하면 추상화 수준을 일관되게 유지하기가 쉬워짐
👉🏻 TO 문단이란.
LOGO 언어에서 사용하는 키워드 ‘TO’ 는 루비나 파이썬에서 사용하는 ‘def’ 와 똑같다.
LOGO 에서 모든 함수는 키워드 ‘TO’ 로 시작한다.
영어로 ‘~하려면’ 의 의미도 된다.
- 핵심은 짧으면서도 ‘한 가지’만 하는 함수!
- 목록 3-7(p. 62) 참고.
- render 함수에서 호출한 SetupTeardownIncluder 함수가 아래에 구현되어 있으며, SetupTeardownIncluder 함수에서 호출한 isTestPage 함수가 그 다음에 작성되어 있음
- 해당 목록을 처음부터 살펴보면 이런식으로 계속 작성되어 있는 것을 확인할 수 있음!
// 목록 3-7의 일부
public static String render(PageData pageData, boolean isSuite) throws Exception {
return new SetupTeardownIncluder(pageData).render(isSuite);
}
private SetupTeardownIncluder(PageData pageData) {
this.isSuite = isSuite;
if(isTestPage())
…
}
private boolean isTestPage() throws Exception {
…
}
Switch 문
- switch 문은 작게 만들기가 어려움
→ case 분기가 2개만 되도 길이가 길어지며, ‘한 가지’ 작업만 하는 switch 문을 만들기도 어려움
→ 본질적으로 n 가지를 처리하기 때문
- switch 문을 완전히 피할 수 는 없으나
- 각 switch 문을 저차원 클래스에 숨기고 절대 반복하지 않는 방법이 있음
→ 다형성 polymorphism 이용
// 목록 3-4(p. 47)
public Money calculatePay(Employee e)
throws InvalidEmployeeType {
switch (e.type) {
case COMMISSIONED:
return calculateCommissionedPay(e);
case HOURLY:
return calculateHourlyPay(e);
…
}
}
- 목록 3-4의 문제점
- 함수가 길다 : 새 직원 유형을 추가하면 더 길어짐
- ‘한 가지’ 작업만 수행하지 않음
- SRP(Single Responsibility Principle) 를 위반함 : 코드를 변경할 이유가 여럿이기 때문
- OCP(Open Closed Principle) 를 위반함 : 새 직원 유형을 추가할 때마다 코드를 변경하기 때문
- 위 함수와 구조가 동일한 함수가 무한정 존재한다는 것 : 예. isPayday(Employee e, Date date) , deliverPay(Employee e, Money pay) 등…
- 이러한 코드를 해결하기 위해 switch 문을 추상 팩토리 Abstract Factory 에 숨김
- 팩토리는 switch 문을 사용해서 적절한 Employee 파생 클래스의 인스턴스를 생성
- calculatePay, isPayday, deliverPay 등과 같은 함수는 Employee 인터페이스를 거쳐 호출됨
→ 다형성으로 인해 실제 파생 클래스의 함수가 실행됨
// 목록 3-5(p. 48)
public abstract class Employee {
public abstract boolean isPayday();
public abstract Money calculatePay();
public abstract void deliverPay(Money pay);
}
public interface EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
}
public class EmployeeFactoryImpl implements EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
switch (r.type) {
case COMMISSIONED:
// Employee 를 채택해서 각각 클래스에서 구현되도록
return new CommissionedEmployee(r);
case HOURLY:
return new HourlyEmployee(r);
…
}
}
}
→ 이렇게 다형적 객체를 생성하는 코드 안에서 ‘한 번만’ switch 문을 작성하도록 함
→ 상속 관계로 숨긴 후에는 다른 코드에 노출하지 않음
(물론 불가피한 경우도 발생할 수 있음)
서술적인 이름 사용하기
- ”코드를 읽으면서 짐작했던 기능을 각 루틴이 그대로 수행한다면 깨끗한 코드라 불러도 되겠다.”
- 이름이 길어도 좋음
- 길고 서술적인 이름이 짧고 어려운 이름보다 좋음
- 길고 서술적인 이름이 서술적인 주석보다 좋음
- 함수 이름을 정할 때는 여러 단어가 쉽게 읽히는 명명법을 사용하기
- 그런 다음, 여러 단어를 사용해 함수 기능을 잘 표현하는 이름을 선택하기
- 이름을 정하느라 시간을 들여도 좋으며 이런저런 이름을 넣어 코드를 읽어보면 더 좋음
→ 최신 IDE 에서 이름 바꾸는 것은 식은 죽 먹기다.
- 이름을 붙일 때는 일관성이 있어야 함
- 모듈 내에서 함수 이름은 같은 문구, 명사, 동사를 사용하기
// 좋은 예.
includeSetupAndTeardownPages
includeSetupPages
includeSuiteSetupPage
includeSetupPage
그렇다면 includeTeardownPages, includeSuiteTeardownPage, includeTeardownPage도 있나요?
당연하다. ‘짐작하는 대로’다.
함수 인수
- 함수에서 이상적인 인수 개수는 0개(무항)
- 다음은 1개(단항)고, 다음은 2개(이항), 3개 이상은 피하는 것이 좋음
- 테스트 관점에서 인수는 더 어려움
→ 여러가지 인수 조합으로 함수를 검증하는 테스트케이스를 작성해봐야 하기 때문
- 최선은 입력 인수가 없는 경우, 차선은 입력 인수가 1개 뿐인 경우
많이 쓰는 단항 형식
- 함수에 인수 1개를 넘기는 이유로 가장 흔한 경우는 두 가지
1. 인수에 질문을 던지는 경우
boolean fileExists(“MyFile”)
2 . 인수를 뭔가로 변환해 결과를 반환하는 경우
// String 형의 파일 이름을 InputStream으로 변환
InputStream fileOpen(“MyFile”)
- 유용한 단항 함수 형식 : 이벤트
- 이벤트 함수는 입력 인수만 있고 출력 인수는 없음
- 프로그램은 함수 호출을 이벤트로 해석해 입력 인수로 시스템 상태를 바꿈
- 이 외의 경우는 가급적 피하는 것이 좋음
- 변환 함수에서 출력 인수를 사용하면 혼란을 일으킴
- 입력 인수를 변환하는 함수라면 변환 결과는 반환값으로 돌려줄 것
// 좋은 예.
StringBuffer transform(StringBuffer in)
// 나쁜 예.
void transform(StringBuffer out)
플래그 인수
- 플래그 인수를 사용하는 함수는 대놓고 한꺼번에 여러 가지를 처리한다는 것을 표현함
→ 함수는 한 가지만 해야한다는 규칙을 어김
- 플래그가 참이면 이걸 하고 거짓이면 저걸 한다는 뜻이기 때문!!
이항 함수
- 이항 함수가 적절한 경우
- 인수 2개가 한 값을 표현하는 요소인 경우
- 두 요소 사이에 자연적인 순서가 있는 경우
// 좋은 예.
// 오히려 new Point(0) 이라면 이상하다고 생각할 것임
Point p = new Point(0, 0)
// 나쁜 예.
// outputStream 과 name 은 한 값을 표현하지도, 자연적인 순서가 있지도 않음
writeField(outputStream, name)
// expected 와 actual 는 자연적인 순서가 아님. 각 인수가 그 위치에 존재한다는 것을 기억해야함
assertEquals(expected, actual)
- 이항 함수가 무조건 나쁜 것은 아니지만 위험이 따른다는 사실을 이해하고 가능하면 단항 함수로 바꾸는 것이 좋음
// 예.
// 1. writeField 메서드를 outputStream 클래스 구성원으로 만들거나
outputStream.writeField(name)
// 2. outputStream 을 현재 클래스 구성원 변수로 만들어 인수를 넘기지 않음
writeField(name)
// 3. FieldWriter 라는 새 클래스를 만들어 구성자에서 outputStream 을 받고 write 메서드 구현 (???)
삼항 함수
- 순서, 주춤, 무시로 야기되는 문제가 두 배 이상 발생하므로 더욱 신중하게 사용해야 함!
- 예시.
// 예상하지 않은 인수의 위치로 인해 자주 주춤하게 됨
assertEquals(message, expected, actual)
// 아래는 그나마 가치가 있는 삼항 함수라는데 왜인지?????
assertEquals(1.0, amount, .001)
인수 객체
- 인수가 2-3개 필요하다면 일부를 독자적인 클래스 변수로 선언할 가능성 고려하기
- 객체를 사용하여 인수를 줄이는 것이 눈속임이라 생각할 수도 있으나,
- x 와 y를 Point 로 묶으면서 하나의 개념을 표현하게 됨
Circle makeCircle(double x, double y, double radius);
Circle makeCircle(Point center, double radius);
인수 목록
- 인수 개수가 가변적인 함수도 필요
- String.format 메서드가 좋은 예
String.format(“%s worked %.2f” hours.”, name, hours);
→ 가변 인수를 전부 동등하게 취급하면 List 형 인수 하나로 취급 가능
→ 이항 함수라고 할 수 있음!
// 실제 String.format 의 선언부
public String format(String format, Object… args)
- 이 역시 인수가 많아지는 것은 주의해야함 (이 때, 가변 인수는 하나로 취급)
동사와 키워드
- 단항 함수는 함수와 인수가 동사/명사 쌍을 이뤄야 함
// 바로 이해하기 쉬움!
write(name)
// 더 좋은 이름 : ‘이름name’이 ‘필드field’라는 사실을 알 수 있음
writeField(name)
- 함수 이름에 키워드를 추가하기
- 즉, 함수 이름에 인수 이름을 넣는 것
// 아래 표기가 더 좋음 : 인수 순서를 기억할 필요가 사라짐
assertEquals(expected, actual)
assertExpectedEqualsActual(expected, actual)
부수 효과를 일으키지 말기
- 부수 효과는 대부분 시간적인 결합 temporal coupling 이나 순서 종속성 order dependency 를 초래함
- 목록 3-6 (p. 55)
→ checkPassword(String userName, String password) 는 암호를 확인하는 함수임
→ but, Session.initialize() 를 호출하며 부수효과를 일으킴
→ 함수 이름을 통해서는 세션을 초기화 한다는 정보를 알 수 없음
→ 의도치 않게 함수를 호출하여 기존 세션 정보를 지워버릴 수 있음
→ 세션을 초기화해도 괜찮은 경우만 호출이 가능
→ 시간적인 결합을 초래함
- 만약 이와 같이 시간적인 결합이 필요하다면 함수 이름에 분명히 명시할 필요가 있음
→ checkPasswordAndInitializeSession()
→ but, 함수가 한 가지만 한다는 규칙을 위반하게 됨
출력 인수
- 개인적으로 출력 인수라는 용어 자체가 와닿지는 않는데,,
- 인수를 출력으로 사용하는 함수를 의미하는 것 같음
- 함수의 인수를 출력 인수로 사용하는 것은 피하기
- 함수에서 상태를 변경해야 한다면 함수가 속한 객체 상태를 변경하는 방식을 택할 것
appendFooter(s);
// 위 함수는 s 가 하는 일에 대해 정확히 알 수 없음
// 무언가에 s를 바닥글로 첨부하는 것인지, s에 바닥글을 첨부하는 것인지
// 함수 선언부를 확인하여 출력 인수라는 사실을 알 수 있음
public void appendFooter(StringBuffer report)
// 출력 인수로 사용하라고 설계한 변수가 this
// 따라서 다음과 같이 사용하는 방식이 좋음
report.appendFooter()
함수 선언부를 찾아보는 행위는 코드를 보다가 주춤하는 행위와 동급이다. 인지적으로 거슬린다는 뜻이므로 피해야 한다.
명령과 조회를 분리하기
- 함수는 뭔가를 수행하거나 뭔가에 답하거나 둘 중 하나만 해야함
- 객체 상태를 변경하거나 아니면 객체 정보를 반환하거나 둘 중 하나만!
// 모호한 코드
// 이름이 attribute 인 속성을 찾아 값을 value 로 설정한 후 성공하면 true를 실패하면 false를 반환하는 함수
public boolean set(String attribute, String value);
// “username”이 “unclebob”으로 설정되어 있는지 확인하는 코드인지,
// “username”을 “unclebob”으로 설정하는 코드인지 의미가 모호함
// -> “set” 이라는 단어가 동사인지 형용사인지 분간하기 어렵기 때문!!
if (set(“username”, “unclebob”))…
→ 구현한 개발자는 “set”을 동사로 의도했으나, if 문에 넣고 보면 형용사로 느껴짐
→ “username 속성이 unclebob으로 설정되어 있다면…” vs. ”username을 unclebob으로 설정하는데 성공하면…”
→ 전자로 읽힘
→ set 이라는 함수 이름을 setAndCheckIfExists 라고 바꿀 수도 있으나 if 문에 넣어보면 여전히 어색함
→ 명령과 조회를 분리해 혼란을 줄 여지를 제거해야 함!!
if (attributeExists(“username”)) {
setAttribute(“username”, “unclebob”);
…
}
오류 코드보다 예외 사용하기
- 명령 함수에서 오류 코드를 반환하는 방식은 명령/조회 분리 규칙을 미묘하게 위반함
- 오류 코드 예시.
- 여러 단계의 중첩 코드를 야기함
if (deletePage(page) == E_OK) {
if (registry.deleteReference(page.name) == E_OK) {
if (configKeys.deleteKey(page.name.makeKey()) == E_OK) {
logger.log("page deleted");
} else {
logger.log("configKey not deleted");
}
} else {
logger.log("deleteReference from registry failed");
}
} else {
logger.log("delete failed");
return E_ERROR;
}
- 예외 사용 예시.
- 오류 처리 코드가 분리되어 코드가 깔끔해짐
try {
deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
}
catch (Exception e) {
logger.log(e.getMessage());
}
Try/Catch 블록
- 위 예시에서 예외를 사용하는 것이 더 깔끔하다고 했지만
- try/catch 블록은 코드 구조에 혼란을 주고, 정상 동작과 오류 처리 동작을 뒤섞을 수 있음
→ 별도 함수로 분리하는 것이 좋음
- 정상 동작과 오류 처리 동작을 분리하면 코드를 이애하고 수정하기가 더 쉬워짐
// 모든 오류를 처리함
public void delete(Page page) {
try {
deletePageAndAllReferences(page);
}
catch (Exception e) {
logError(e);
}
}
// 실제로 페이지를 제거 -> 예외를 처리 하지 않음
private void deletePageAndAllReferences(Page page) throws Exception {
deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
}
private void logError(Exception e) {
logger.log(e.getMessage());
}
오류 처리도 한 가지 작업
- 오류를 처리하는 함수도 ‘함수’ 이므로 오류만 처리해야 함
→ 위 예제와 같이 함수에 try 키워드가 있다면 함수는 try 문으로 시작해서 catch/finally 문으로 끝나야 함
Error.java 의존성 자석
- 오류 코드를 반환한다는 말은 어디선가(클래스, 열거형 변수 등) 오류 코드를 정의 한다는 뜻
public enum Error {
OK,
INVALID,
…
}
→ 이런 클래스는 의존성 자석 magnet 임
→ 이 것을 다른 클래스에서 사용하는데 Error enum 이 변한다면 이것을 사용하는 클래스 전부를 다시 컴파일 하고 다시 배치해야 함
→ Error 클래스 변경이 어려워짐
- 오류 코드 대신 예외를 사용하면 새 예외는 Exception 클래스에서 파생함
→ 재컴파일/재배치 없이 새 예외 클래스를 추가할 수 있음 (OCP, Open Closed Principle 를 보여주는 예)
반복하지 말기
- 코드가 중복되는 것은 코드 길이가 길어지며, 알고리즘이 변하는 경우 중복되는 모든 곳을 변경해야 함
- 또한 한 곳이라도 잘 못 수정하면 오류가 발생할 확률이 그만큼 높아짐
- E. F. 커드는 자료의 중복을 제거하기 위해 관계형 데이터베이스에 정규 형식을 만들었고
- 객체 지향 프로그래밍은 코드를 부모 클래스로 몰아 중복을 없앰
- 구조적 프로그래밍, AOP(Aspect Oriented Programming), COP(Component Oriented Programming) 모두 어떤 면에서 중복 제거 전략이라고 할 수 있음
→ 소프트웨어 개발에서 지금까지 일어난 혁신은 소스 코드에서 중복을 제거하려는 노력으로 보임
→ 중복을 없애자!
구조적 프로그래밍
- 누군가는 구조적 프로그래밍 원칙을 따르며 데이크스트라는 모든 함수와 함수 내 모든 블록에 입구와 출구는 하나만 존재해야 한다 고 말함 (에르허츠 데이크스트라의 구조적 프로그래밍 원칙)
- 즉, 함수는 return 문이 하나여야 하며, 루프 내에서 break나 continue 를 사용해서는 안되고, goto는 절대로 안된다는 뜻
- 하지만 함수가 작아진다면 위 규칙은 큰 이익을 제공하지 못함
- 따라서 함수를 작게 만든다면 간혹 return, break, continue 를 여러 차례 사용해도 괜찮음
- 오히려 때로는 단일 입/출구 규칙 single entry-exit rule 보다 의도를 표현하기 쉬워짐
(하지만 goto 문은 큰 함수에서만 의미가 있으므로, 작은 함수에서 피해야 함)
함수 짜기
- 처음에는 길고 복잡하며, 들여쓰기 단계도 많고 중복도 많음. 인수 목록도 길고 이름 또한 즉흥적
- but, 서투른 코드라도 이 코드를 테스트하는 단위 테스트 케이스 만들기
- 그런 다음 코드를 다듬고, 함수를 만들고, 이름을 바꾸고, 중복을 제거하며, 메서드를 줄이고 순서를 바꿈
- 때로는 전체 클래스를 쪼개기도 함
- 이 와중에도 코드는 항상 단위 테스트를 통과하도록
- 이렇게 서툴고 어수선한 코드를 다듬다보면 위의 규칙을 따르는 함수가 만들어질 수 있음!
모든 시스템은 특정 응용 분야 시스템을 기술할 목적으로 프로그래머가 설계한 도메인 특화 언어(Domain Specific Language, DSL) 로 만들어진다. 함수는 그 언어에서 동사며, 클래스는 명사다.
대가 master 프로그래머는 시스템을 (구현할) 프로그램이 아니라 (풀어갈) 이야기로 여긴다. 프로그래밍 언어라는 수단을 사용해 좀 더 풍부하고 좀 더 표현력이 강한 언어를 만들어 이야기를 풀어간다. 시스템에서 발생하는 모든 동작을 설명하는 함수 계층이 바로 그 언어에 속한다.
하지만 진짜 목표는 시스템이라는 이야기를 풀어가는 데 있다는 사실을 명심하기 바란다. 여러분이 작성하는 함수가 분명하고 정확한 언어로 깔끔하게 같이 맞아 떨어져야 이야기를 풀어가기가 쉬워진다는 사실을 기억하기 바란다.
'# Reading > --- 개발서적' 카테고리의 다른 글
[Clean Code] 6장. 객체와 자료 구조 (0) | 2022.03.01 |
---|---|
[Clean Code] 5장. 형식 맞추기 (0) | 2022.02.28 |
[Clean Code] 4장. 주석 (0) | 2022.02.25 |
[클린 아키텍처] 객체 지향 프로그래밍 (0) | 2021.04.19 |
[클린 아키텍처] 구조적 프로그래밍 (0) | 2021.04.12 |