본문 바로가기
Topos Uranos

요약

지금까지 강좌의 프로그램은 모두 한 파일에 들어갔다. 천 줄짜리 프로그램은 더 이상 한 번에 읽히지 않고, 그 함수들은 다른 프로그램에도 쓰일 만하다. 이 수업은 프로그램을 나누는 법을 가르친다. 모듈이 제공하는 것을 선언하는 헤더, 그것을 정의하는 구현 파일, 그리고 그것을 사용하는 main.cpp 파일이다. 인클루드 가드를 소개하고, 링커의 고전적인 두 오류와 inline 키워드의 현대적인 의미를 설명하는 단일 정의 규칙, 바뀐 것만 재컴파일하는 분할 컴파일을 다룬 뒤, 앞선 수업들의 함수로 만든 자체 라이브러리로 마무리한다.

학습 목표

  1. 프로그램을 선언이 담긴 헤더, 구현 파일, main.cpp 파일로 나누고 컴파일한다.
  2. 인클루드 가드를 작성하고 그것이 어떤 문제를 해결하는지 설명한다. #pragma once 지시문을 모든 컴파일러가 받아들이지만 표준에는 없는 확장으로 알아본다.
  3. 단일 정의 규칙을 설명하고, 링커의 메시지(“multiple definition”, “undefined reference”)로부터 그 위반을 진단한다.
  4. -c 옵션으로 분할 컴파일하고 오브젝트 파일을 링크하며, 각 파일이 바뀔 때 무엇이 재컴파일되는지 설명한다.

인터페이스와 구현

모듈성

계약이 있는 함수, 예컨대 함수로 설계하기 수업의 함수들은 약속하는 것(선언과 계약에 적힌다)과 그것을 지키는 방법(본문에 적힌다)을 이미 나누고 있다. 호출하는 쪽에 필요한 것은 앞의 것뿐이다. 그렇다면 두 부분을 서로 다른 파일에 두지 못할 이유가 없다. 모듈은 공통의 목적을 지닌 함수들(과 그것들이 쓰는 자료형)의 묶음이다. 모듈의 인터페이스는 사용하는 쪽에 제공하는 것이고, 구현은 그것을 해내는 방식이다. C++에서 인터페이스는 헤더(확장자 .hpp, 또는 C에서 물려받은 .h)에 쓰고, 구현은 같은 이름의 .cpp 파일에 쓴다.

여기서 세 가지 이점이 나온다. 부분별로 이해된다. 모듈을 쓰려면 헤더만 읽으면 된다. 부분별로 컴파일된다. 구현을 바꾸어도 인터페이스만 쓰는 쪽을 재컴파일할 필요가 없다. 그리고 부분별로, 각 계약에 비추어 테스트된다. 이는 프로그램 테스트 수업에서 배운다. “학술적 글쓰기를 위한 LaTeX” 강좌도 “긴 문서” 수업에서 긴 문서를 같은 방식으로 나눈다.

세 파일

이 예의 평면 기하 모듈은 세 함수를 제공하며, 그중 둘은 5.3강에서 왔다. 헤더는 그것들을 선언만 하고, 각각에 계약을 붙인다. # 기호로 시작하는 세 줄은 뒤의 절에서 설명한다. 지금은 모든 헤더에 이 세 줄이 있다는 것만 알면 된다.

geometry.hpp
// geometry.hpp: 평면 기하 모듈의 인터페이스.#ifndef GEOMETRY_HPP#define GEOMETRY_HPP​// 반지름이 radius인 원의 넓이.// 사전 조건: radius >= 0.// 사후 조건: 반지름이 radius인 원의 넓이를 반환한다.double circleArea(double radius);​// 세 길이가 삼각형의 세 변이 되는지 여부.// 사전 조건: 없음.// 사후 조건: 변이 a, b, c인 삼각형이 존재할 때, 그리고 그때에만 true를 반환한다.bool isTriangle(double a, double b, double c);​// 변이 a, b, c인 삼각형의 넓이.// 사전 조건: isTriangle(a, b, c).// 사후 조건: 변이 a, b, c인 삼각형의 넓이를 반환한다(헤론 공식).double triangleArea(double a, double b, double c);​#endif

구현 파일은 자신의 헤더를 포함하고 함수들을 정의한다. 필요한 표준 라이브러리 헤더(제곱근을 위한 <cmath> 헤더, π\pi의 값을 위한 <numbers> 헤더)는 헤더가 아니라 여기서 포함한다. 모듈을 쓰는 쪽에는 그것들이 필요 없기 때문이다.

geometry.cpp
// geometry.cpp: 평면 기하 모듈의 구현.#include "geometry.hpp"​#include <cmath>#include <numbers>​double circleArea(double radius){    return std::numbers::pi * radius * radius;}​bool isTriangle(double a, double b, double c){    return a > 0 && b > 0 && c > 0 && a < b + c && b < a + c && c < a + b;}​double triangleArea(double a, double b, double c){    const double s{(a + b + c) / 2};    return std::sqrt(s * (s - a) * (s - b) * (s - c));}

마지막으로 main.cpp 파일은 헤더를 포함하고 모듈을 사용하되, 그 구현은 한 줄도 보지 않는다.

C++ 프로그램
#include <print>​#include "geometry.hpp"​int main(){    std::println("반지름이 2인 원: {:.4f}", circleArea(2.0));    std::println("변이 3, 4, 5인 삼각형이 있는가? {}", isTriangle(3.0, 4.0, 5.0));    std::println("그 넓이: {}", triangleArea(3.0, 4.0, 5.0));    std::println("변이 1, 2, 3이면? {}", isTriangle(1.0, 2.0, 3.0));}
출력
반지름이 2인 원: 12.5664변이 3, 4, 5인 삼각형이 있는가? true그 넓이: 6변이 1, 2, 3이면? false

#include "geometry.hpp" 지시문의 따옴표는 무엇보다 먼저 그것을 포함하는 파일이 있는 폴더에서 파일을 찾고, #include <print> 지시문의 꺾쇠괄호는 시스템 폴더에서 찾는다. 따옴표는 자신의 파일을 위한 것이다. geometry.cpp 파일은 자신의 헤더를 맨 먼저 포함한다. 그러면 헤더 자신에게 필요한 포함이 빠져 있을 때, 오류가 다른 사람의 파일이 아니라 모듈을 컴파일할 때 나타난다. 그리고 변이 1, 2, 3인 삼각형은 존재하지 않으므로(3=1+23 = 1 + 2), triangleArea 함수의 사전 조건에 따라 그것을 미리 물어야 한다.

컴파일할 때 명령은 두 .cpp 파일을 받는다. 헤더는 #include 지시문으로 각 파일에 들어가므로 이름을 적지 않는다. 강좌의 옵션은 명령마다 되풀이하지 않도록 터미널 변수인 FLAGS 변수에 저장하고, $FLAGS 표기로 다시 불러온다. 이것은 Linux와 macOS의 표기이다. PowerShell에서는 변수를 다르게 쓰므로, 옵션을 모두 적거나 편집기에서 컴파일하는 것이 좋다(“분할 컴파일” 절).

터미널
$ FLAGS="-std=c++23 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -g -fsanitize=address,undefined -fno-sanitize-recover=undefined"$ g++ $FLAGS main.cpp geometry.cpp -o main$ ./main반지름이 2인 원: 12.5664변이 3, 4, 5인 삼각형이 있는가? true그 넓이: 6변이 1, 2, 3이면? false
예시세 파일이 거치는 길

이 그림은 프로그램, 컴파일러, C++ 수업의 그림을 이 프로젝트에 맞게 다시 쓴 것이다.

증명

  1. 프로그래머는 세 파일을 쓴다. 헤더 하나와 .cpp 파일 둘이다. 컴파일러에 넘기는 것은 .cpp 파일뿐이다.

  2. 전처리기는 geometry.hpp 파일을 그것을 포함하는 각 파일에 복사한다. 따라서 선언의 복사본이 번역 단위마다 하나씩 두 개 있는데, 이는 올바르다. 함수는 원하는 만큼 여러 번 선언할 수 있다.

  3. 각 단위는 따로 컴파일되어 자신의 오브젝트 파일이 된다. main.cpp 파일을 컴파일할 때 컴파일러는 geometry.cpp 파일을 보지 않는다. 각 호출의 자료형을 검사하는 데는 선언으로 충분하다.

  4. 링커는 두 오브젝트 파일과 표준 라이브러리를 합치고, main.o 파일에 있는 circleArea 함수의 각 호출을 geometry.o 파일에 있는 정의와 연결한다.

흔한 오류는 구현 파일을 포함하는 것, 곧 #include "geometry.cpp" 지시문이다. 게다가 그 파일을 명령에도 적으면 그 함수들이 두 번 정의되어, “단일 정의 규칙” 절에서 설명할 규칙에 따라 링크가 실패한다. 적지 않으면 main.cpp 파일은 다시 하나의 텍스트가 되어 바뀔 때마다 통째로 재컴파일된다. .cpp 파일은 컴파일하는 것이고, 헤더는 포함하는 것이다.