要約
ここまで、コースのプログラムはどれも一つのファイルに収まっていた。千行のプログラムはもう一度には読めず、その関数はほかのプログラムにも役立てる価値がある。このレッスンでは、プログラムを分割する方法を学ぶ。モジュールが提供するものを宣言するヘッダ、それを定義する実装ファイル、そしてそれを使う main.cpp である。インクルードガードを紹介し、続いてリンカの二つの古典的なエラーと inline の現代的な意味を説明する単一定義規則、変更されたものだけを再コンパイルする分割コンパイルを扱い、最後にこれまでのレッスンの関数でできた自作ライブラリで締めくくる。
学習目標
- プログラムを、宣言を含むヘッダ、実装ファイル、
main.cppに分割し、コンパイルする。 - インクルードガードを書き、それがどんな問題を解決するかを説明する。
#pragma onceを、すべてのコンパイラが受け付けるが規格には含まれない拡張として見分ける。 - 単一定義規則を説明し、その違反をリンカのメッセージ(「multiple definition」「undefined reference」)から診断する。
-cで分割コンパイルしてオブジェクトファイルをリンクし、各ファイルが変更されたときに何が再コンパイルされるかを説明する。
インタフェースと実装
モジュール性
契約付きの関数、たとえばレッスン 関数による設計 の関数は、約束する内容(宣言と契約に書かれる)と、それを果たす方法(本体に書かれる)をすでに分けている。呼び出す側に必要なのは前者だけである。そうであれば、二つの部分を別々のファイルに置くことを妨げるものは何もない。モジュールとは、共通の目的をもつ関数(とそれらが使う型)のまとまりである。そのインタフェースは使う側に提供するものであり、その実装はそれを実現する方法である。C++ では、インタフェースはヘッダ(拡張子 .hpp、または C から受け継いだ .h)に書き、実装は同じ名前の .cpp ファイルに書く。
ここから三つの利点が出てくる。部分ごとに理解できる。モジュールを使うにはヘッダを読めば足りる。部分ごとにコンパイルできる。実装を変更しても、インタフェースだけを使う側を再コンパイルする必要はない。そして部分ごとに、それぞれの契約に照らしてテストできる。これはレッスン プログラムのテスト で学ぶ。学術文書作成のための LaTeX のコースも、レッスン「長い文書」で長い文書を同じように分割している。
三つのファイル
この例の平面幾何モジュールは三つの関数を提供し、そのうち二つはレッスン 5.3 から来ている。ヘッダはそれらを宣言するだけで、それぞれに契約を添える。# で始まる三行はこの後の節で説明する。今のところは、どのヘッダにもこの三行があると知っていれば足りる。
// 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>、 のための <numbers>)はヘッダではなくここでインクルードする。モジュールを使う側はそれらを必要としないからである。
// 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 はヘッダをインクルードしてモジュールを使う。その実装は一行も見ない。
#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 の三角形は存在しない()ので、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++ の図をこのプロジェクトに当てはめたものである。
証明
プログラマは三つのファイルを書く。ヘッダが一つと
.cppファイルが二つである。コンパイラに渡すのは.cppだけである。プリプロセッサは
geometry.hppを、それをインクルードする各ファイルにコピーする。したがって宣言のコピーは翻訳単位ごとに一つずつ、計二つあるが、それで正しい。関数は何度でも宣言できる。各単位は別々にコンパイルされ、それぞれのオブジェクトファイルになる。
main.cppをコンパイルするとき、コンパイラはgeometry.cppを見ない。各呼び出しの型を確かめるには宣言で足りる。リンカは二つのオブジェクトファイルと標準ライブラリを結合し、
main.oのcircleAreaの各呼び出しを、geometry.oにある定義に結びつける。
よくある誤りは、実装ファイルをインクルードすること、つまり #include "geometry.cpp" である。さらにそれをコマンドにも挙げると、その関数は二度定義され、節「単一定義規則」で説明する規則によってリンクが失敗する。挙げなければ、main.cpp は再び一つのテキストになり、変更のたびに丸ごと再コンパイルされる。.cpp ファイルはコンパイルするもの、ヘッダはインクルードするものである。
コンテンツを読み込んでいます…