[5/20] 결과 있는 함수
글을 시작하기에 앞서 해당 시리즈는 Allen Downey, Ben Lauwens의 저서인 Think Julia: How to Think Like a Computer Scientist 를 바탕으로 작성된 글임을 알려드립니다.
이 포스트는 Fruitful Functions를 한글로 요약 정리한 글입니다.
결과 있는 함수 (Fruitful Functions)
줄리아가 제공하는 많은 함수들은 반환 값을 생성한다. 하지만 우리가 사용했던 Turtle()
과 같은 결과 없는 함수(void function)는 명령을 수행하기만 하고 반환 값을 제공하지는 않는다. 이번 장에서는 결과 있는 함수를 작성하는 법을 알아볼 것이다.
반환 값
함수를 호출하면 일반적으로 변수에 할당하거나 표현식의 일부로 사용하는 반환 값이 생성된다.
1 |
|
결과 없는 함수는 반환 값을 가지고 있지 않다. 정확하게는 반환 값으로 nothing
을 가지고 있다. 결과 있는 함수의 첫 번째 예시로 원의 넓이를 반환하는 area()
를 살펴볼 것이다.
1 |
|
위의 코드는 return
문을 사용하여 값을 보여준다. return
문은 함수의 결괏값을 다음의 반환 값으로 사용하게 만들어주는 명령문이다. 결과 있는 함수는 return
문을 내장하고 있기 때문에 쓸 필요가 없다. 따라서 수정된 코드는 아래와 같다.
1 |
|
함수 본문의 마지막에 위치해 있는 표현식의 결과가 값으로 반환된다. 반면에 함수 내부에서 정의되는 임시 변수 a
의 경우는 return
문을 사용해야 디버깅하기 쉽다. 때때로 조건문의 조건마다 return
문을 가지는 것은 유용하다.
1 |
|
이런 return
문들은 각각의 대체 조건문에 들어가 있으며, 조건에 따라 작동된다. return
문이 실행되면 뒤의 명령문들은 실행되지 않고 종료되는데, 이때 뒤에 있던 명령문들을 데드 코드(dead code)이라고 한다.
결과 있는 함수에서는 프로그램의 모든 경로가 return
문으로 끝나게 작성하는 것이 좋다. 아래 예시를 확인해보자.
1 |
|
위의 함수는 x
가 0이 되면 어떤 조건도 참이 아니고 반환할 return
문도 없기 때문에 올바른 함수가 아니다. 만약 x
가 0인채로 함수가 진행된다면 반환 값으로 nothing
을 준다.
1 |
|
Tip 줄리아는 절대값을 반환해주는 abs()
를 내장하고 있다.
점진적인 개발 (Incremental Development)
크고 복잡한 함수들을 작성할수록 디버깅에 많은 시간을 쏟아야 할 것이다. 복잡한 프로그램들을 점점 향상시키기 위해서, 점진적인 개발이라는 과정을 배우는 것은 중요하다. 점진적인 개발의 목표는 작은 단위의 코드들을 계속 테스트해야하고 수정해야하는 디버깅에 소요되는 시간을 줄이는 것이다. 예로 두 점인 (x1,y1)와 (x2,y2)의 거리를 구하려고 한다. 피타고라스 정리에 따르면 수학식은 다음과 같다.
\[\begin{equation} \label{eq1} d= \sqrt{(x2-x1)^2+(y2-y1)^2} \end{equation}\]
위의 수학식을 줄리아에서 구현하기 위해서는 어떻게 해야할까? 첫 번째로는 무엇을 인수로 제공하며, 얻으려는 반환 값은 무엇인지 정하는 것이다. 위의 사례에서는 두 점인 (x1,y1)와 (x2,y2)을 인수로 제공하면 된다. 반환 값의 경우는 소수로 표현된 거리를 얻을 것이다. 인수와 반환 값을 결정하면 바로 아래와 같은 코드를 완성할 수 있다.
1 |
|
위의 코드는 거리를 계산하지는 않으며, 본문에 따라 항상 0을 반환한다. 그러나 본문에 다른 명령문을 추가하기 전에 코드가 작동하는지 확인할 수 있다. 작동을 확인하기 위해 아래의 코드를 입력해보자.
1 |
|
위의 코드에서 두 점은 수평 거리가 3이 되고 수직 거리가 4가 되도록 선택하였다. 그 결과 두 점의 거리 값은 5이며, 반환 값도 5가 되어야 한다. 이와 같이 코드를 테스트할 때는 정답을 알고 있는 상태에서 해야 올바르게 작동하고 있는지 확인할 수 있다.
위의 테스트를 통해서 해당 함수가 문법적으로는 문제가 없다고 확인하였다. 그러면 이제부터는 본문에 명령문들을 추가해보자. 다음 단계는 x2−x1
과 y2−y1
을 설정하는 것이다. 함수 내부에 임시 변수로서 두 식의 값을 저장하도록 설정해보자. 그 다음 @show
를 사용하여 임시 변수가 올바르게 생성되는지 확인해보자.
1 |
|
위의 함수가 제대로 작동했다면, dx = 3
과 dy = 4
가 보였을 것이다. 만약 그렇다면 우리는 함수가 올바른 인수를 받아 제대로 작동하고 있다고 안심할 수 있다.
다음 명령문으로 dx
와 dy
을 제곱하여 더하는 계산을 추가한다.
1 |
|
다시 위의 함수를 작동했을 때에는 d²
값이 25로 나와야 한다. 만약 올바르게 작동한다면 마지막으로 제곱근을 계산해주는 sqrt()
를 추가해준다.
1 |
|
코드가 제대로 작동했다면, 우리는 위의 수학식을 코드로 올바르게 구현한 것이다.
최종적으로 사용한 위의 함수는 반환 값을 가지고 있지만 결과를 출력하지는 않는다. 따라서 반환 값이 올바른지 체크하기 위해서는 println()
과 같은 함수로 직접 츌력해서 확인해야 한다. 이런 작업을 스캐폴딩(scaffolding)이라고 한다. 이런 함수들은 디버깅을 할 때 도움이 되지만, 나중에 최종 프로그램을 구현할 때는 제거해야 한다.
초보자가 함수를 작성할 때는 명령문을 한 줄 또는 두 줄만을 추가하여 올바르게 작동하는지 확인해야 한다. 그 과정이 익숙해지면 더 큰 덩어리 단위로 명령문을 작성하고 디버깅할 수 있다.
요약하자면 함수를 개발하는 방법은 다음과 같다.
- 프로그램을 작동하여 확인하면서 작은 부분들을 변경하자. 언제라도 오류가 발생하면 어느 부분에서 문제가 생긴 것인지 알고 있어야 한다.
- 함수 내부에 임시 변수를 사용하여 값들을 저장하면서 값이 올바른지 확인하자.
- 프로그램이 작동하면 스캐폴딩을 제거하고, 명령문들을 복합 표현식으로 사용하는 등 코드를 간단하게 수정하자.
컴포지션 (composition)
함수를 작성할 때, 필요하다면 미리 작성한 함수를 가져와 사용할 수 있다. 예를 들어 원의 중심과 둘레의 점을 두 점으로 하여 원의 면적을 계산하는 함수를 살펴 보자.
중심점을 변수 xc
와 yc
로 표현하고, 둘레점을 xp
와 yp
로 표현한다. 그 이후 첫 번째 단계로 두 지점 사이의 거리인 원의 반경을 찾기로 한다. 우리는 윗 장에서 두 점의 거리를 구하는 distance()
를 작성했기 때문에 이를 이용할 것이다.
1 |
|
다음 단계로는 우리가 만들었던 area()
를 사용하여 원의 면적을 구한다.
1 |
|
마지막으로 위의 변수들을 하나의 함수로 캡슐화해준다.
1 |
|
임시 변수인 radius
와 result
는 디버깅을 할 때 유용하지만, 굳이 임시 변수를 사용하지 않고 인수 자리에 함수를 바로 입력하여 더 간결하게 표현할 수 있다.
1 |
|
불 함수 (Boolean Functions)
함수는 불(true or false)를 반환할 수 있으며, 이는 종종 복잡한 테스트를 함수 내부에 숨기는데 유용하다. 예는 다음과 같다.
1 |
|
이것은 yes/no 질문이라고 불리는 전형적인 불 함수이다. isdivisible()
은 x값을 y값으로 나눈 나머지 값에 따라 true
또는 false
를 반환한다.
1 |
|
==
연산자는 자동으로 불 표현식의 결괏값을 반환하므로 함수를 더 간결하게 작성할 수 있다.
1 |
|
불 함수는 종종 조건문에 사용된다.
1 |
|
또는 다음과 같이 작성할 수도 있다.
1 |
|
하지만 위의 코드에서 true
는 불필요하다.
재귀 정의 (More Recursion)
우리는 고작 줄리아의 작은 서브셋(subset)들을 배웠지만, 사실 이 서브셋들은 완벽한 프로그래밍 언어이다. 지금까지 습득한 언어로 대부분의 수학 계산식들을 표현할 수 있다. 즉, 프로그램을 작성하는데 어려움이 없다는 것이다.
프로그래밍 언어가 수학 계산식을 표현할 수 있다는 사실은 수학자 앨런 튜링(Alan Turing)에 의해서 증명되었으며, 해당 이론을 튜링 이론이라고 한다. 만약 튜링 이론에 관심이 있다면 Michael Sipser의 저서인 Introduction to the Theory of Computation을 추천한다.
지금부터는 우리가 배운 도구들을 사용하여 만들어진 몇 가지 재귀 함수를 살펴볼 것이다. 재귀 정의는 어떤 단어를 설명하는데 있어 그 단어를 선택한다는 점에서 순환 정의와 유사하다.
vorpal An adjective used to describe something that is vorpal.
위의 예시를 보면 vorpal이란 단어를 정의하는데 vorpal이란 단어가 쓰이는 것을 볼 수 있다. 재귀함수도 이와 유사한 구조를 가지고 있다. 재귀 함수의 예로 계승 함수(factorial function)을 살펴보자.
\[ n! = \begin{cases} 1 & \text{if n=0 \]}\ n(n-1)! & \ \end{cases} $$
위의 정의는 n
이 0이면 1을 반환하고, 만약 n
이 0이상이라면 n
과 (n-1)!
을 곱하라고 설명한다.
예를 들면 3!은 3과 2!를 곱하고, 2!는 2와 1!을 곱하며, 1!는 1과 0을 곱한다. 따라서 3!는 32110이기에 6인 것이다.
재귀 정의를 이용하여 위의 수학식을 함수로 작성할 수 있다. 첫 번째 단계는 인수가 무엇인지 결정하는 것이다. 이 경우에서는 정수가 인수로 사용된다는 것이 명확하다.
1 |
|
만약 인수가 0이라면, 1을 반환해야 하므로 조건문을 사용한다.
1 |
|
이 부분이 가장 흥미로운데, 만약 인수가 0이 아니라면 우리는 n
과 (n-1)!
를 곱하기 위해서 재귀 함수을 사용해야 한다.
1 |
|
n
에 3을 부여한 위 코드의 진행 과정은 다음과 같다.
3
은0
이 아니기 때문에,else
문으로 넘어가3
과2!
을 곱하려고 한다.2
은0
이 아니기 때문에,else
문으로 넘어가2
과1!
을 곱하려고 한다.1
은0
이 아니기 때문에,else
문으로 넘어가1
과0!
을 곱하려고 한다.n
이0
이기 때문에,0!
은 첫 번째 코드를 따라 1을 반환한다.0!
가1
을 반환했기 때문에1
과 곱해져1!
을 반환한다.1!
가1
을 반환했기 때문에2
과 곱해져2!
을 반환한다.2!
가2
을 반환했기 때문에3
과 곱해져6
을 반환한다.
결국 마지막으로 작동된 명령문의 결과값인 6이 반환 값으로 보여준다. 또한 로컬 변수인 result
나 recurse
도 함수가 작동을 멈추는 동시에 사라진다.
Tip 줄리아는 계승 함수(factorial function)를 제공한다.
믿음의 도약 (Leap of Faith)
실행 흐름을 따르는 것은 프로그램을 읽는 하나의 방법이다. 그러나 때에 따라선 믿음의 도약이라고 부르는 방법도 사용할 수 있다. 믿음의 도약이란 함수가 어떻게 작동하는지를 파악하기 전에 함수를 호출하여 올바른 결괏값을 도출하는지 확인하여 그 함수가 잘 작동하고 있다고 믿는 것이다. 즉, 프로그램의 실행 흐름을 보는 것이 아니라 결괏값만을 보고 프로그램이 올바르게 작동하고 있는지 파악하는 것이다.
실제로 내장 함수를 사용할 때 우리는 이 방법을 많이 사용한다. 예를 들어 cos()
나 exp()
를 사용할 때, 해당 함수의 본문을 검사하지 않으며, 결괏값만 보고 믿음을 주는 것이다. 재귀 프로그램 또한 실행 흐름을 따르는 대신에 재귀 호출이 되었다고 가정하여 결괏값을 확인한다.
연습해보기
가장 재귀적으로 정의된 가장 보편적인 수학 표현식 중 하나인 피보나치를 작성해보자. 피보나치에 대한 정의는 이 링크를 확인하면 된다.
\[ fib(n) = \begin{cases} 0 & \text{if n=0 \]}\ 1 & \ fib(n-1)+fib(n-2) & \ \end{cases} $$
위 식을 줄리아 코드로 표현하면 다음과 같다.
1 |
|
이 함수에서 실행 흐름대로 파악하려면 n
이 아주 작은 경우에도 머리가 복잡하다. 그렇기 때문에 믿음의 도약에 따라 재귀 함수가 올바르게 작동한다고 가정한후 n
에 3 정도를 넣어보면 빠르게 좋은 결과를 얻을 수 있다.
데이터 타입 확인하기
민약에 fact()
에 인수로 1.5
를 넣으면 어떻게 될까?
1 |
|
무한 재귀로 작동된 에러 결과가 나왔다. 어떻게 그게 가능할까? fact()
는 n
이 0
일때 함수 작동이 끝나는 재귀 정의를 가지고 있다. 1.5
는 0
이 아니기 때문에 else
문으로 실행되며 첫 번째 실행 이후 n
값이 0.5
가 된다. 그 다음 두 번째 실행에서는 n
값이 -0.5
가 된다. 결국 n
값이 0
이 되지 못하기 때문에 무한대로 재귀가 작동하는 결과가 초래된 것이다.
따라서 이 문제를 해결하기 위해서는 소수점 숫자도 인수로 받을 수 있게 fact()
를 일반화하거나, 데이터 타입을 미리 확인하라는 명령문을 추가할 수 있다. 첫 번째 방법은 감마 함수라고 하며 이 책의 범위를 벗어나기 때문에, 우리는 두 번째 방법을 선택할 것이다.
내장 연산자인 isa
를 이용하여 인수 유형을 확인할 수 있다.
1 |
|
첫 번째 if
문은 정수가 아닌 수들에 대한 에러 메시지를 도출하며, 두 번째 elseif
문은 음수에 대한 에러 메시지를 도출한다. 에러 메시지가 출력될 경우 그 함수의 결과는 nothing
을 반환한다.
1 |
|
두 검사를 모두 통과하면 n
이 양수이거나 0임이 증명되기 때문에, 바로 재귀 함수로 넘어갈 수 있다.
이런 프로그램 구조는 가이드라인을 보여준다. 처음 두 조건이 가이드 역할을 하여 오류를 일으킬 수 있는 값에서 구출하고 오류를 막아준다.
디버깅
큰 프로그램들을 작은 함수들로 분해하는 것은 자연스러운 디버깅 포인트들을 만들어준다. 만약 함수가 작동하고 있지 않다면, 아래의 세 가지 가능성을 고려야봐야 한다.
- 함수의 인수가 잘못 배정된 경우 (전제조건 오류)
- 함수 자체에 오류가 있는 경우 (사후조건 오류)
- 반환 값이나 사용되는 방식에 문제가 있는 경우 (함수의 연결부분)
첫 번째의 가능성을 배제하기 위해, println()
을 추가하고 나오는 결괏값을 확인하여 데이터 타입을 정확히 확인하라. 아니면 해당 함수의 전제 조건을 다시 정확히 확인하라. 인수가 양호하다면 각 return
문 앞에 println()
을 추가하고 반환 값을 표시해서 가능하면 결과값을 확인하라. 옳은 결과인지 확인하기 쉬운 값을 인수로 주는 것이 좋다. 함수가 작동하는 것 같으면 함수 호출을 보고 반환 값이 올바르게 사용되는지 확인하라. 함수의 시작과 끝에 println()
을 추가하면 실행 흐름을 더 잘 볼 수 있다. 예로 아래의 함수는 println()
문이 포함된 fact()
코드 이다.
1 |
|
space
는 출력의 들여 쓰기를 제어하는 공백 문자열이다.
1 |
|
만약에 실행 흐름이 헷갈리면, 위와 같은 출력들이 도움이 될 것이다. 효과적인 스캐폴딩을 개발하는 것은 조금 시간이 걸리겠지만, 작은 스캐폴딩으로도 많은 디버깅 요소들을 찾을 수 있다.