요약
앞의 두 수업에서는 함수가 무엇이며 그 데이터가 어떻게 들어가고 나오는지를 배웠다. 이 수업에서는 어떤 함수를 쓸지 결정하는 법을 배운다. 방법은 단계적 세분화다. 아직 존재하지 않는 함수들을 각자의 계약과 함께 호출하는 짧은 main 함수를 쓰고, 각 함수로 내려가면서 몇 줄로 쓸 수 있는 부분에 이를 때까지 계속한다. 이 수업은 좋은 인터페이스의 기준, “함수 하나에 작업 하나”라는 규칙과 그 위반을 드러내는 징후, 그리고 서로 다른 타입 위의 같은 작업에 같은 이름을 주는 오버로딩을 정한다. 이 기준들로 main 함수 안에 있던 4단원의 알고리즘을 다시 쓰고, 각각을 따로 테스트한다. 모든 것을 하나로 묶는 개념은 하향식 분해다. 잘 설계된 프로그램은 그 자체의 명세처럼 읽힌다.
학습 목표
- 단계적 세분화로 문제를 하위 문제로 분해하고 분해 트리를 쓴다.
- 본문을 쓰기 전에 각 함수의 인터페이스(이름, 매개변수, 반환값, 계약)를 설계한다.
- 4단원의 통짜 프로그램을 응집력 있는 함수들의 모음으로 다시 쓰고, 테스트용
main함수로 각각을 따로 테스트한다. - “함수 하나에 작업 하나”라는 규칙과 숨은 효과가 없는지를 기준으로 설계를 판단한다.
- 함수를 오버로드하고, 각 호출이 어느 버전을 고르는지 예측하며, 어떤 호출이 모호해지는 이유를 설명한다.
단계적 세분화
문제의 진술에서 몇 줄짜리 main으로
4단원의 프로그램들은 통째로 main 함수 안에 쓰였다. 한 화면에 들어가는 동안은 그것도 참을 만하다. 그러나 그렇게 쓴 이백 줄짜리 프로그램은 모든 것을 동시에 머릿속에 담고 있을 것을 요구하고, 전체를 한꺼번에 테스트하는 것만 허용한다. 단계적 세분화, 또는 하향식 설계는 작업의 순서를 뒤집는다. 먼저 프로그램이 하는 일을 문제의 언어로, 아직 존재하지 않는 함수들에 대한 호출의 나열로 쓴다. 그다음 그 함수 하나하나를 새롭고 더 작은 문제로 다루며, 아무것도 분해하지 않고 본문을 쓸 수 있을 만큼 단순한 함수에 이를 때까지 같은 과정을 되풀이한다.
아래의 문제를 예로 든다. 정수 에 대하여, 그 진약수( 자신이 아닌 양의 약수)의 합이 과 같으면 완전수, 작으면 부족수, 크면 과잉수라 한다. , 따라서 28은 완전수이고, 8은 부족수(), 12는 과잉수()이다. 의 값을 읽어 그 진약수의 합과 분류를 쓰는 프로그램을 원한다. 설계의 첫 단계는 main 함수이며, 무엇보다 먼저 쓴다.
#include <cstdint>#include <iostream>#include <print>#include <string>enum class Kind { deficient, perfect, abundant };// n의 양의 약수의 합.// 사전 조건: n >= 1.// 사후 조건: n 자신을 포함하여 n의 모든 양의 약수의 합을 반환한다.std::int64_t divisorSum(int n);// 진약수의 합에 따른 n의 분류.// 사전 조건: n >= 1.// 사후 조건: 합이 n보다 작은지, 같은지, 큰지에 따라 deficient, perfect, abundant를 반환한다.Kind classify(int n);// 출력하기 위한 분류의 이름.// 사전 조건: 없음.// 사후 조건: “부족수”, “완전수”, “과잉수” 가운데 하나를 반환한다.std::string kindName(Kind kind);int main(){ int n{}; std::cin >> n; std::println("{}의 진약수: 합은 {}", n, divisorSum(n) - n); std::println("{}의 분류: {}", n, kindName(classify(n)));}/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':main.cpp:27:(.text+0x22a): undefined reference to `divisorSum(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x3fd): undefined reference to `classify(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x40d): undefined reference to `kindName[abi:cxx11](Kind)'collect2: error: ld returned 1 exit status이 프로그램은 경고 하나 없이 컴파일된다. 각 호출이 그 선언과 맞기 때문인데, 함수 수업에서 배웠듯이 컴파일러가 확인하는 것은 그것뿐이다. 실패하는 것은 링크다. 링커는 정의를 찾지만 찾지 못한다(“undefined reference”, 즉 정의가 없는 참조. 덧붙은 [abi:cxx11] 표시는 std::string 타입을 반환하는 함수에 GCC가 붙이는 내부 표시다). 이 상태는 이 시점에서 설계의 상태 그대로다. 인터페이스는 정해졌고 본문은 아직 없다. 그렇다면 main 함수는 이미 프로그램의 명세로 읽히며, 세 선언은 남은 작업이고 하나하나가 원래 문제보다 작다.
분해 트리
두 번째 단계는 각 선언으로 내려간다. kindName 함수는 세 경우의 판단이다. classify 함수는 divisorSum 함수에 기댄다. 어떤 수가 완전수인 것은 그 모든 약수의 합이 과 같을 때다. 그리고 divisorSum 함수는 약수를 쌍으로 찾는 반복문(수치 알고리즘 수업의 것)이며, 에서 멈춘다. 결과는 트리로 그린다. 루트는 문제 전체이고, 각 노드의 자식은 그것을 푸는 하위 문제이며, 잎은 언어가 이미 제공하는 연산이다.
그림은 설계한 순서대로 트리를 한 단계씩 펼친다.
증명
첫 단계는 문제 전체로,
main함수가 맡지만 아직 분해되지 않았다.두 번째 단계는 다섯 가지 연산으로 이루어진
main함수다. 읽기, 합하기, 분류하기, 이름 붙이기, 쓰기. 각 노드는 호출 하나이며, 라이브러리의 호출은 이미 있고 나머지는 선언되었지만 아직 남아 있다.세 번째 단계는
classify함수를 분해한다. 이 함수는divisorSum함수를 호출하고 비교할 뿐이다. 같은 함수가 트리에 두 번 나타나지만 한 번만 쓴다. 재사용은 코드를 쓰기 전에 트리에서 보인다.마지막 단계는 잎이다. 나누어떨어지는지의 판정과 쌍의 한계로, 둘 다 언어의 연산이다. 여기에 이르면 세분화는 끝난다. 더 분해할 것이 남지 않았기 때문이다.
트리가 완성되면 아래에서 위로 정의를 써서, 각 함수가 이미 쓰인 함수에 기대게 한다.
#include <cstdint>#include <iostream>#include <print>#include <string>enum class Kind { deficient, perfect, abundant };// n의 양의 약수의 합.// 사전 조건: n >= 1.// 사후 조건: n 자신을 포함하여 n의 모든 양의 약수의 합을 반환한다.std::int64_t divisorSum(int n){ std::int64_t sum{0}; for (int d{1}; d <= n / d; ++d) { if (n % d == 0) { sum += d; if (d != n / d) { sum += n / d; } } } return sum;}// 진약수의 합에 따른 n의 분류.// 사전 조건: n >= 1.// 사후 조건: 합이 n보다 작은지, 같은지, 큰지에 따라 deficient, perfect, abundant를 반환한다.Kind classify(int n){ const std::int64_t proper{divisorSum(n) - n}; if (proper < n) { return Kind::deficient; } if (proper == n) { return Kind::perfect; } return Kind::abundant;}// 출력하기 위한 분류의 이름.// 사전 조건: 없음.// 사후 조건: “부족수”, “완전수”, “과잉수” 가운데 하나를 반환한다.std::string kindName(Kind kind){ if (kind == Kind::deficient) { return "부족수"; } if (kind == Kind::perfect) { return "완전수"; } return "과잉수";}int main(){ int n{}; std::cin >> n; std::println("{}의 진약수: 합은 {}", n, divisorSum(n) - n); std::println("{}의 분류: {}", n, kindName(classify(n)));}2828의 진약수: 합은 2828의 분류: 완전수결과의 세 가지 특징은 이 방법 덕분이다. 첫째, 아래 부분을 쓰는 동안 main 함수는 바뀌지 않았다. 둘째, 합은 std::int64_t 타입으로 반환된다(<cstdint> 헤더의 64비트 정수로, 재귀 수업에서도 쓴다). 의 값이 int 타입의 최댓값에 가까우면 약수의 합이 그것을 넘을 수 있기 때문이며, 이를 인터페이스에서 정해 두면 호출하는 쪽 모두가 보호된다. 셋째, 각 함수는 따로 확인한다. divisorSum(28) 호출은 56, divisorSum(1) 호출은 1, divisorSum(36) 호출은 91을 주어야 하며, 마지막 경우에 쌍 의 경우는 한 번만 센다. 이 수업의 마지막 절은 이 확인들을 프로그램으로 바꾼다.
콘텐츠를 불러오는 중…