개발로그/Clean Code

[3장] 함수

떼닝 2022. 11. 2. 15:46

- 프로그래밍 초창기에는 시스템을 루틴과 하위 루틴으로 나눴으나, 지금은 함수만 살아남았다.

- 가장 기본적인 단위가 함수.

# 좋지 않은 코드
def testableHtml(pageData:PageData, includeSuiteSetup:bool) -> str:
	try:
    	wikiPage = pageData.getWikiPage()
        buffer = StringBuffer()
        if pageData.hasAttribute("Test"):
        	if includeSuiteSetup:
            	suiteSetup = PageCrawlerImpl.getInheritedPage(SuiteResponder.SUITE_SETUP_NAME, wikiPage)
                if suiteSetup != NULL:
                	pagePath = suiteSetup.getPageCrawler().getFullPath(suiteSetup)
                	pagePathName = PathParser.render(pagePath)
                    buffer.append("!include -setup .")
                    buffer.append(pagePathName)
                    buffer.append("\n")
            setup = PageCralwerImpl.getInheritedPage("Setup", wikiPage)
            if setup != NULL:
            	setupPath = wikiPage.getPageCrawler().getFullPath(setup)
                setupPathName = PathParser.render(setupPath)
                buffer.append("!include -setup .")
                buffer.append(setupPathName)
                buffer.append("\n")
        buffer.append(pageData.getContent())
        if pageData.hasAttribute("Test"):
        	teardown = PageCrawlerImpl.getInheritedPage("TearDown", wikiPage)
            if teardown != NULL:
            	tearDownPath = wikiPage.getPageCralwer().getFullPath(teardown)
                String tearDownPathName = PathParser.render(tearDownPath)
                buffer.append("\n")
                buffer.append("!include -teardown .")
                buffer.append(tearDownPathName)
                buffer.append("\n")
            if includeSuiteSetup:
            	suiteTeardown = PageCrawlerImpl.getInheritedPage(SuiteResponder.SUITE_TEARDOWN_NAME, wikiPage)
            if suiteTeardown != NULL:
            	pagePath = suiteTeardown.getPageCrawler().getFullPath(suiteTeardown)
                pagePathName = PathParser.render(pagePath)
                buffer.append("!include -teardown .")
                buffer.append(pagePathName)
                buffer.append("\n")
                
    pageData.setContent(str(buffer))
    return pageData.getHtml()

- 위 코드는 추상화 수준도 너무 다양하고, 코드도 너무 길다, ...

- 아래 코드는 메서드 몇 개를 추출하고, 이름 몇 개를 변경하고, 구조를 조금 변경한 형태

# 위 코드 리팩터링 버전
def renderPageWithSetupsAndTeardowns(pageData:PageData, isSuite:bool)->str:
	try:
    	isTestPage = pageData.hasAttribute("Test")
        if isTestPage:
        	testPage = pageData.getWikiPage()
            newPageContent = StringBuffer()
            includeSetupPages(testPage, newPageContent, isSuite)
            newPageContent.append(pageData.getContent())
            includeTeardownpages(testPage, newPageContent, isSuite)
            pageData.setContent(str(newPageContent))
   return pageData.getHTML()

 

작게 만들어라!

- 함수를 만드는 첫째 규칙은 '작게!', 둘째 규칙은 '더 작게!'

- 증명할만한 근거는 없지만... 지금까지의 경험을 바탕으로 그리고 오랜 시행착오를 바탕으로 필자는 작은 함수가 좋다고 확신.

- 각 함수가 이야기 하나를 표현할 수 있도록, 명백하게 구성해야 함.

# 리-리팩토링한 코드
def renderPageWithSetupsAndTeardowns(pageData:PageData, isSuite:bool) -> str:
	try:
    	if isTestPage(pageData):
        	includeSetupAndTeardownPages(pageData, isSuite)
    return pageData.getHtml()

블록과 들여쓰기

- if문, else문, while문 등에 들어가는 블록은 한 줄이어야 한다!

- 바깥을 감싸는 함수(enclosing function)이 작아질 뿐 아니라, 블록 안에서 호출하는 함수 이름을 적절히 짓는다면 코드 이해도 쉬워짐

- 중첩 구조가 생길만큼 함수가 커져서는 안 된다. 

한 가지만 해라!

- 함수는 한 가지를 해야 한다. 그 한 가지를 잘 해야 한다. 그 한 가지만을 해야 한다.

- 사실 리-리팩토링한 코드는 세 단계를 하고 있다고 할 수 있는데, 이는 지정된 함수 이름 아래에서 추상화 수준이 하나임.

- 우리가 함수를 만드는 이유는 큰 개념을(함수 이름을) 다음 추상화 수준에서 여러 단계로 나눠 수행하기 위해서.

- 다른 표현이 아니라 의미 있는 이름으로 다른 함수를 추출할 수 있다면 그 함수는 여러 작업을 하고 있다고 할 수 있음.

함수 내 섹션

- 한 가지 작업만 하는 함수는 자연스럽게 섹션으로 나누기 어렵다.

함수 당 추상화 수준은 하나로!

- 함수가 '한 가지' 작업만 하려면 함수 내 모든 문장의 추상화 수준이 동일해야 한다.

- 한 함수 내에 추상화 수준을 섞으면 코드를 읽는 사람이 헷갈린다.. 특정 표현이 근본 개념인지 아니면 세부사항인지 구분하기 어렵기 때문

- 근본 개념과 세부 사항을 뒤섞기 시작하면 사람들이 함수에 세부사항을 점점 더 추가하게 된다.

위에서 아래로 코드 읽기: 내려가기 규칙

- 위에서 아래로 프로그램을 읽으면 함수 추상화 수준이 한 번에 한 단계씩 낮아진다.

- TO(그냥 ~하려면의 의미) 문단을 읽듯이 프로그램이 읽혀야 하며, 각 TO 문단은 현재 추상화 수준을 설명한다.

- TO 문단을 읽어내려 가듯이 코드를 구현하면 추상화 수준을 일관되게 유지하기가 쉬워진다.

- 각 함수는 일정한 추상화 수준을 유지한다.

Switch문

- switch문은 작게 만들기 어렵다.

- 다형성(polymorphism)을 이용하여 각 switch문을 저차원 클래스에 숨기고 절대로 반복하지 않는 방법이 있다.

# python에는 switch-case라는 게 딱히 없고... if-elif-else로...
def calculatePay(e:Employee)->Money:
    etype = e.type
    if etype == "COMMISSIONED":
        return calculateCommissionedPay(e)
    elif etype == "HOURLY":
        return calculateHourlyPay(e)
    elif etype == "SALARIED":
        return calculateSalariedPay(e)
    else:
        raise(InvalidEmployeeType(e.type))

- 위 코드의 문제점

  - 함수가 길다

  - '한 가지' 작업만 수행하지 않는다

  - SRP(Single Responsibility Principle)을 위반한다

  - OCP(Open Closed Principle)을 위반한다

  - 위 함수와 구조가 동일한 함수가 무한정 존재할 수 있음. (ex. isPayday(e:Employee, date:Date)와 deliverPay(e:Employee, pay:Money)

# 위 코드의 문제점 해결
class Employee():
	def isPayday(self)->bool :
    	pass
    def calculatePay(self)->Money :
		pass
    def deliveryPay(self, pay:Money):
		pass

class EmployeeFactory():
	def makeEmployee(r:EmployeeRecord):
    	pass
        
class EmployeeFactoryImpl(EmployeeFactory):
	def makeEmployee(r:EmployeeRecord):
    	if r.type == "COMMISIONED":
        	return CommisionedEmployee(r)
        elif r.type == "HOURLY":
        	return HourlyEmployee(r)
        elif r.type == "SALARIED":
        	return SalariedEmployee(r)
        else:
        	raise Exception(InvalidEmployeeType(r.type))

서술적인 이름을 사용하라

- 함수가 하는 일을 좀 더 잘 표현하는, 서술적인 이름이 좋은 이름이라고 할 수 있음.

- 함수가 작고 단순할수록 서술적인 이름을 고르기가 쉬워짐

- 때로는 긴 이름이 더 나을 때도 있음. 길고 서술적인 이름이 짧고 어려운 이름보다, 길고 서술적인 주석보다 긴 이름이 좋다.

- 함수 이름을 정할 때는 여러 단어가 쉽게 읽히는 명명법 사용하고, 이 단어들을 사용해 함수 기능을 잘 표현하는 이름을 선택.

- 이름을 붙일 때는 일관성이 있어야 한다.

  - 모듈 내에서 함수 이름은 같은 문구, 명사, 동사를 사용해야 한다. (includeSetupAndTeardownPages, includeSetupPages, ...)

함수 인수

- 함수에서 이상적인 인수 개수는 순서대로 0개(무항), 1개(단항), 2개(이항). 3개 이상은 피하는 것이 좋음

- 인수는 개념을 이해하기 어렵게 만든다. ( includeSetupPageInto(PageContent)보다는 includeSetupPage()가 더 이해하기 쉬움 )

- 테스트 관점에서는 갖가지 인수 조합으로 함수를 검증해야 하기 때문에 더 어렵다.

- 출력 인수는 입력 인수보다 더 어렵다. 

- 최선은 입력 인수가 없는 경우이며, 차선은 입력 인수가 1개뿐인 경우

많이 쓰는 단항 형식

- 함수에 인수 1개를 넘기는 이유 두 가지

  - 1. 인수에 질문을 던지는 경우. boolean fileExists("MyFile")

  - 2. 인수로 뭔가를 변환해 결과를 반환하는 경우. InputStream fileopen("MyFile")

- 함수 이름을 지을 때는 두 경우를 분명히 구분해야 하고, 언제나 일관적인 방식으로 두 형식을 사용해야 한다.

- 이벤트는 아주 유용한 단항 함수 형식. 입력 인수만 있고, 출력 인수는 없다.

  - 프로그램은 함수 호출을 이벤트로 해석해 입력 인수로 시스템 상태를 바꾼다. (ex. passwordAttemptFailesNtimes(int attempts))

  - 이벤트라는 사실이 코드에 명확히 드러나야 하기 때문에, 이름과 문맥을 주의해서 선택해야 한다.

- 단항 함수는 가급적 피해야 하며, 변환 함수에서 출력 인수 사용하면 안 됨.

- 입력 인수를 그대로 돌려주는 함수라 할지라도 변환 함수 형식을 따르는 것이 변환 형태를 유지할 수 있기 때문에 좋음.

플래그 인수

- 플래그 인수는 추하다... (ㅡ_ㅡ)

- 함수로 bool값을 넘기는 관례는 함수가 한꺼번에 여러 가지를 처리한다고 대놓고 공표하는 것!이므로 정말 끔찍하다.

이항 함수

- 인수가 2개인 함수는 인수가 1개인 함수보다 이해하기 어렵다. (writeField(name)은 writeField(outputStream, name)보다 쉬움)

- 이항 함수가 적절한 경우도 있긴 하다...! 직교 좌표계 점을 표현할 때...

- 이항 함수가 무조건 나쁜 건 아니지만 그만큼 위험이 따른다는 사실을 이해하고 가능하면 단항 함수로 바꾸도록 애써야 한다.

삼항 함수

- 인수가 3개인 함수는 인수가 2개인 함수보다 훨씬 더 이해하기 어렵다 (당연한 거 아님?)

- assertEquals(1.0, amount, .001)은 그다지 음험하지 않은 삼항 함수. 삼항 함수로 사용할 가치가 충분하다.

인수 객체

- 인수가 2~3개 필요하다면 일부를 독자적인 클래스 변수로 선언할 가능성을 짚어봐야 한다.

def makeCircle(x:float, y:float, radius:float) -> Circle:
	pass
def makeCircle(center:Point, radius:float) -> Circle:
	pass

- 위 코드에서 x와 y를 묶었듯이 변수를 묶어 넘기려면 이름을 붙여야 하므로 결국은 개념을 표현하게 된다.

인수 목록

- 때로는 인수 개수가 가변적인 함수도 필요하다.

- 가변 인수를 취하는 모든 함수에 같은 원리가 적용된다.

- 가변 인수를 취하는 함수는 단항, 이항, 삼항 함수로 취급할 수 있지만, 이를 넘어서는 인수를 사용할 경우에는 문제가 있다.

동사와 키워드

- 함수의 의도나 인수의 순서와 의도를 제대로 표현하려면 좋은 함수 이름이 필수다.

- 단항 함수는 함수와 인수가 동사/명사 쌍을 이뤄야 한다. (write(name), 더 나은 이름은 writeField(name))

- 함수 이름에 인수 이름을 넣는, 키워드를 추가하는 형식. (assertEquals보다 assertExceptedEqualsActual(expected, actual)이 좋음) -> 인수 순서를 기억할 필요가 없어진다.

부수 효과를 일으키지 마라

- 부수 효과는 거짓말이다.

- 함수에서 한가지를 하겠다고 약속하고선 클래스 변수를 수정하거나, 함수로 넘어온 인수나 시스템 전역 변수를 수정한다.

- 많은 경우 시간적인 결합(temporal coupling)이나 순서 종속성(order dependency)을 초래한다.

# UserValidator.py
class UserValidator:
	cryptographer = ""	# Cryptographer type
    
    def checkPassword(userName:str, password:str)->bool:
    	user = UserGateway.findByName(userName)
        if user != User.NULL:
        	codedPhrase = user.getPhraseEncodedByPassword()
            phrase = cryptographer.decrypt(codedPhrase, password)
            if "Valid Password" == phrase:
            	Session.initialize()
                return True
        return False

- 위 코드에서 함수가 일으키는 부수 효과는 Session.initialize() 호출이다. checkPassword함수는 암호를 확인할 뿐.

- 함수 이름만 보고 함수를 호출하는 사용자는 사용자를 인증하면서 기본 세션 정보를 지워버릴 위험에 처하게 됨.

- 이런 부수 효과가 시간적인 결합을 초래한다.

- checkPassword 함수는 특정 상황에서만, 세션을 초기화해도 괜찮은 경우에만 호출이 가능하다. 

- 만약 시간적인 결합이 필요하다면 함수 이름에 분명히 명시해야 한다.

출력 인수

- 우리는 일반적으로 인수를 함수 입력으로 해석.

- 선언부 appendFooter(report:str)를 봐야 호출부 appendFooter(s)에서의 s가 출력 인수라는 것을 알 수 있었음

- 함수 선언부를 찾아보는 행위는 인지적으로 거슬린다는 뜻이므로 피해야 한다.

- 일반적으로 출력 인수는 피해야 한다. 함수에서 상태를 변경해야 한다면 함수가 속한 객체 상태를 변경하는 방식을 택해야

명령과 조회를 분리하라!

- 함수는 뭔가를 수행하거나, 뭔가에 답하거나 둘 중 하나만 해야 한다. 객체 상태를 변경하거나, 객체 정보를 반환하거나 둘 중 하나.

def set(attribute:str, value:str) -> bool:
	pass
    
# ------------------------------------------

if set("username", "unclebob")) ...

- 위의 함수는 이름이 attribute인 속성을 찾아 값을 value로 설정한 후 성공하면 true, 실패하면 false 반환.

- "set"이라는 단어가 동사인지 형용사인지 분간하기 어려워 함수를 호출하는 코드만 봐서는 의미가 모호함

- 함수를 구현한 개발자는 "set"을 동사로 의도했지만, if문 안에 넣고 보면 형용사로 느껴진다.

- 진짜 해결책은 명령과 조회를 분리해 혼란을 애초에 뿌리뽑는 방법이다.

if attributeExists("username"):
	setAttribute("username", "unclebob")

오류 코드보다 예외를 사용하라!

- 명령 함수에서 오류 코드를 반환하는 방식은 명령/조회 분리 규칙을 미묘하게 위반한다.

if deletePage(page) == E_OK:
	pass

- 위 코드는 동사/형용사 혼란을 일으키지 않는 대신 여러 단계로 중첩되는 코드를 야기한다.

- 오류 코드를 반환하면 호출자는 오류 코드를 곧바로 처리해야 한다는 문제에 부딪힌다.

if deletePage(page) == E_OK:
	if registry.deleteReference(page.name) == E_OK:
    	if configKeys.deleteKey(page.name.makeKey()) == E_OK:
        	logging.info("page deleted")
        else:
        	logging.info("configKey not deleted")
    else:
    	logging.info("deleteReference from registry failed")
else:
	logging.info("delete failed")
    return E_ERROR

- 오류 코드 대신 예외를 사용하면 오류 처리 코드가 원래 코드에서 분리되므로 코드가 깔끔해진다.

try:
	deletePage(page)
    registry.deleteReference(page.name)
    configKeys.deleteKey(page.name.makeKey())
catch (Exception as e):
	logging.info(e.getMessage())

Try/Catch 블록 뽑아내기

- try/catch 블록은 원래 추하다... 코드 구조에 혼란을 일으키며 정상 동작과 오류 처리 동작을 뒤섞는다

- 그러므로 try/catch 블록을 별도 함수로 뽑아내는 편이 좋다.

def delete(page:Page):
	try:
    	deletePageAndAllReferences(page)
    catch (Exception as e):
    	logError(e)

def deletePageAndAllReferences(page:Page):
	deletePage(page)
    registry.deleteReference(page.name)
    configKeys.deleteKey(page.name.makeKey())
    
def logError(e:Exception):
	logging.info(e.getMessage())

- 위에서 delete 함수는 모든 오류를 처리하기 때문에 코드를 이해하기 쉽다.

- 정상 동작과 오류 처리 동작을 분리하면 코드를 이해하고 수정하기 쉬워진다.

오류 처리도 한 가지 작업이다

- 오류를 처리하는 함수는 오류만 처리해야 마땅하다.

- 함수에 키워드 try가 있다면 함수는 try문으로 시작해 catch/finally 문으로 끝나야 한다.

Error.java 의존성 자석

- 오류 코드를 반환한다는 이야기는, 클래스든 열거형 변수든, 어디선가 오류 코드를 정의한다는 뜻이다.

public enum Error{
	OK,
    INVALID,
    NO_SUCH,
    LOCKED,
    OUT_OF_RESOURCES,
    WAITING_FOR_EVENT;
}

- 위와 같은 클래스는 의존성 자석(magnet)이다.

- Error enum이 변한다면 이를 사용하는 클래스 전부를 다시 컴파일하고 다시 배치해야 하기 때문에 클래스 변경이 어려워진다.

- 그래서 새 오류 코드를 추가하는 대신 기존 오류 코드를 재사용하여 예외를 사용하게 된다.

- 오류 코드 대신 예외를 사용하면 새 예외는 Exception 클래스에서 파생되므로 재컴파일/재배치 없이도 새 예외 클래스 추가 가능

반복하지 마라!

- 중복은 문제다. 코드 길이가 늘어날 뿐 아니라 알고리즘이 변하면 여러 곳을 다 손봐야 하고, 어느 한 곳이라도 빠뜨릴 경우 오류 발생 가능

- 중복을 없애면 모듈 가독성이 크게 높아짐

- 구조적 프로그래밍, AOP(Aspect Oriented Programming), COP(Component Oriented Programming) 모두 어떤 면에서 중복 제거 전략.

구조적 프로그래밍

- Dijkstra의 구조적 프로그래밍 원칙 : 모든 함수와 함수 내 모든 블록에 입구와 출구가 하나만 존재해야 한다. 함수는 return 문이 하나여야 하고, 루프 안에서 break, continue, goto 사용해선 안됨.

- 함수를 작게 만든다면 간혹 return, break, continue를 여러 차례 사용해도 단일 입/출구 규칙보다 의도를 표현하기 쉬워져서 괜찮다. 

- goto문은 큰 함수에서만 의미가 있으므로, 작은 함수에서는 피해야만 한다.

함수를 어떻게 짜죠?

- 처음에는 길고 복잡하고, 들여쓰기 단계도 많고 중복된 루프도 많지만 코드를 다듬고, 이름을 바꾸고, 함수를 만드는 과정 등을 거침으로써 좋은 함수를 얻을 수 있게 된다.

- 처음부터 딱 나오는 건 아니기 때문에... 괜찮다! 수정하면 된다!

결론

- 모든 시스템은 특정 응용 분야 시스템을 기술할 목적으로 프로그래머가 설계한 도메인 특화 언어로 만들어진다.

- 함수는 그 언어에서 동사며, 클래스는 명사다.

- master 프로그래머는 시스템을 구현할 프로그램이 아니라 풀어갈 이야기로 여긴다. 이것이 진짜 목표!

- 작성하는 함수가 분명하고 정확한 언어로 깔끔하게 같이 맞아떨어져야 이야기를 풀어가기가 쉬워진다는 사실을 기억하길 바란다!

# 위에 import 부분 생략
class SetUpTeardownIncluder:
	def __init__(self):
    	self.pageData = 0	# PageData type
        self.isSuite = 0		# bool type
        self.testPage = 0	# WikiPage type
        self.newPageContent = ""
        self.pageCralwer = 0	# PageCrawler type
        
	def render(self, pageData:PageData)->str:
    	return self.render(pageData, False)
        
    def render(self, pageData:PageData, isSuite:boolean)->str:
    	return self.SetupTeardownIncluder(pageData).render(isSuite)
        
    def setUpTearDownIncluder(self, pageData:PageData):
    	self.pageData = pageData
        self.testPage = pageData.getWikiPage()
        self.pageCrawler = testPage.getPageCrawler()
        self.newPageContent = ""
        
    def render(self, isSuite:bool)->str:
    	self.isSuite = isSuite
        if self.isTestPage():
        	self.includeSetupAndTeardownPages()
        return self.pageData.getHtml()
        
    def isTestPage(self)->bool:
    	return self.pageData.hasAttribute("Test")
        
    def includeSetupAndTeardownPages(self):
    	self.includeSetupPages()
        self.includePageContent()
        self.includeTeardownPages()
        self.updatPageContent()
        
    def includeSetupPages(self):
    	if self.isSuite:
	        self.includeSuiteSetupPage()
        self.includeSetupPage()
        
    def includeSuiteSetupPage(self):
    	self.include(SuiteResponder.SUITE_SETUP_NAME, "-setup")
        
    def includeSetupPage(self):
    	self.include("SetUp", "-setup")
        
    def includePageContent(self):
    	self.newPageContent.append(pageData.getContent())
        
    def includeTeardownPages(self):
    	self.includeTearDownPage():
        if self.isSuite:
        	self.includeSuiteTeardownPage()
            
    def includeTeardownPage(self):
    	self.include("TearDown", "-teardown")
        
    def includeSuiteTeardownPage(self):
    	self.include(SuiteResponder.SUITE_TEARDOWN_NAME, "-teardown")
        
    def updatePageContent(self):
    	self.pageData.setContent(str(newPageContent))
        
    def include(self, pageName:str, arg:str):
    	self.inheritedPage = self.findInheritedPage(pageName)
        if self.inheritedPage != NULL:
        	self.pagePathName = self.getPathNameForPage(self.inheritedPage)
            self.buildIncludeDirective(self.pagePathName, arg)
            
    def findInheritedPage(self, pageName:str):
    	return self.PageCrawlerImpl.getInheritedPage(pageName, self.testPage)
        
    def getPathNameForPage(self, page:WikiPage):
    	self.pagePath = self.pageCrawler.getFullPath(page)
        return self.PathParser.render(self.pagePath)
        
    def buildIncludeDirective(self, pagePathName:str, arg:str):
    	self.newPageContent.append("\n!include ")
        self.newPageContent.append(arg)
        self.newPageContent.append(" .")
        self.newPageContent.append(pagePathName)
        self.newPageContent.append("\n")