C++のクラス設計では、内部データを外部から直接触れないように隠蔽し、公開すべき操作だけをインターフェースとして示すことが重要です。
本記事では、public/privateの違いと使い分けを、目的・設計方針・実践例・落とし穴まで順に解説し、変更に強く安全なクラスを作るための手がかりを示します。
C++のカプセル化とアクセス指定子の基本
クラス設計におけるデータ隠蔽の目的(情報隠蔽と保守性)
クラスのデータ隠蔽は、内部実装の詳細を外部から遮断し、公開インターフェース(操作)だけで利用させることで、不変条件の維持・変更容易性・安全性を高めます。
内部表現を隠しておけば、実装変更(データ型・アルゴリズム・キャッシュ追加など)が利用側に波及しにくく、保守コストを抑えられます。
さらに、外部コードによる不正な状態遷移を予防でき、バグの混入を減らせます。
アクセス指定子の役割と適用範囲(メンバ/メソッド/継承)
アクセス指定子は、メンバ変数・メンバ関数・ネストした型・静的メンバに対する可視性を規定します。
宣言はラベルのように機能し、以降の宣言に適用されます。
- public
どこからでもアクセス可能。公開インターフェースの契約を表します。
- private
クラス内部からのみアクセス可能(派生クラスからも不可)。実装詳細の隠蔽に使います。
継承におけるポイントは次の通りです。
- privateメンバは派生クラスからアクセスできません(アクセス不可)。
- public継承では、基底クラスのpublicはpublicのまま利用者に露出します。
- private継承では、基底のpublic/protectedメンバが派生内ではprivateとして扱われ、外部に露出しない設計にできます(ただし本記事のテーマは主にpublic/privateの使い分けです)。
以下の短い例は、privateメンバが派生クラスからアクセスできないことを示します(コメント参照)。
#include <iostream>
class Base {
private:
int secret_ = 42;
public:
int get() const { return secret_; }
};
class Derived : public Base {
public:
void tryAccess() {
// secret_ は private なのでアクセス不可(コンパイルエラー)
// std::cout << secret_ << "\n";
std::cout << get() << "\n"; // public経由ならOK
}
};
int main() {
Derived d;
d.tryAccess();
}
42
classとstructのデフォルト可視性の違い(private vs public)
C++ではclass
とstruct
の機能はほぼ同じですが、デフォルトの可視性が異なります。
キーワード | メンバのデフォルト可視性 | 継承のデフォルト可視性 |
---|---|---|
class | private | private |
struct | public | public |
データ構造的に「すべて公開で良い」場合のみstruct
を選び、クラス設計では明示的にpublic:
/private:
を書くのが安全です。
publicとprivateの違い(C++クラスの挙動と影響)
public:公開インターフェースとしての契約
publicは利用者に提供する契約です。
公開するほど将来の互換性の制約が増すため、公開するメソッドは責務が明確で、意味が安定している必要があります。
ドキュメント化とテストの対象にし、引数・戻り値・例外・計算量の期待値まで含めて契約を定義するのが望ましいといえます。
private:実装詳細の隠蔽と不変条件の保護
privateは実装の自由度を担保します。
メンバをprivateにすることで、不変条件(例:長さは非負、IDは一意、ソート順を維持など)をクラス内部だけで制御でき、外部から不正値を注入される可能性が下がります。
また、内部表現の変更(型の変更、キャッシュの追加、ロック戦略の調整)を行っても、public APIを変えなければ利用側の修正は不要です。
派生・利用側への影響と変更容易性(API安定化)
- publicは「外部契約」なので変更コストが高い。慎重に露出範囲を決めます。
- privateは「内部契約」なので変更コストが低い。後から差し替えやすい。
- 派生クラス設計でも、publicに露出する拡張点を明確化し、実装詳細はprivateに収めると、APIの安定化につながります。
public/privateの使い分け方針(ベストプラクティス)
最小公開の原則とインターフェース設計
公開するメンバは最小限とし、意図を明確にするメソッド名・引数・戻り値で表現します。
データそのものの公開よりも、操作(コマンド)と問い合わせ(クエリ)を定義し、状態遷移をコントロールします。
利用者が達成したい「ドメイン上の行為」を表すメソッドを公開するのが基本です。
不変条件を守るAPI設計とconstメンバー関数
不変条件はコンストラクタ・セッター・状態変更メソッド内で検証し、破壊的変更を伴わない操作はconst
メンバー関数で宣言します。
const
は並行性の観点でも安全性を高め、誤用の早期発見に役立ちます。
副作用のない問い合わせはconst
にするというルールを徹底すると品質が安定します。
実装詳細はprivateに集約し依存を減らす
ヘッダに依存を持ち込みすぎない工夫として、以下が有効です。
- privateメンバに実装型(コンテナ、サードパーティ型)を隠蔽する
- 実装詳細を
.cpp
に追い出す(前方宣言の活用) - 必要ならPimplイディオムで非公開実装を分離する
これによりビルド時間短縮、差し替え容易性、ABI安定化に寄与します。
実践例で学ぶ使い分け(悪い例→良い例)
悪い例:publicなデータメンバで不変条件が壊れる
幅と高さは非負という不変条件を想定します。
publicなデータメンバだと、利用側が簡単に壊してしまいます。
#include <iostream>
struct RectangleBad {
public:
int width;
int height;
int area() const { return width * height; }
};
int main() {
RectangleBad r{10, 5};
std::cout << "area=" << r.area() << "\n";
// 不変条件を壊す(外部から負数を代入可能)
r.width = -7;
std::cout << "area=" << r.area() << "\n"; // 負の面積という不正状態
}
area=50
area=-35
このように、publicなデータは容易に不正状態へ遷移し、バグの温床になります。
良い例:privateメンバ+publicメソッドで意図を表現
privateでデータを隠し、コンストラクタやメソッドで検証します。
問い合わせはconst
で提供します。
#include <iostream>
#include <stdexcept>
class Rectangle {
private:
int w_;
int h_;
static int ensure_non_negative(int v, const char* name) {
if (v < 0) throw std::invalid_argument(std::string(name) + " must be >= 0");
return v;
}
public:
Rectangle(int w, int h)
: w_(ensure_non_negative(w, "width"))
, h_(ensure_non_negative(h, "height")) {}
void setWidth(int w) { w_ = ensure_non_negative(w, "width"); }
void setHeight(int h) { h_ = ensure_non_negative(h, "height"); }
int width() const { return w_; }
int height() const { return h_; }
int area() const { return w_ * h_; }
};
int main() {
try {
Rectangle r{10, 5};
std::cout << "area=" << r.area() << "\n";
r.setWidth(12);
std::cout << "area=" << r.area() << "\n";
r.setHeight(-3); // ここで例外
} catch (const std::exception& e) {
std::cout << "error: " << e.what() << "\n";
}
}
area=50
area=60
error: height must be >= 0
この設計では、外部から不変条件を破壊できません。
public APIは「問い合わせ」と「正当化された状態変更」だけが露出されます。
変更に強いクラス設計へのリファクタリング手順
内部表現を変更してもpublic APIが不変なら、利用側は影響を受けません。
たとえば、整数ピクセル単位から倍精度メートル単位への変換や、キャッシュ導入をしたい場合でも、privateに閉じていれば安全に差し替えられます。
以下は、面積のキャッシュを追加しても外部APIは変更しない例です。
#include <iostream>
#include <stdexcept>
class RectangleV2 {
private:
double w_m_; // 単位を m に変更(内部表現の差し替え)
double h_m_;
mutable double cache_area_m2_ = -1.0; // キャッシュ(mutableはconstクエリ可)
static double ensure_non_negative(double v, const char* name) {
if (v < 0) throw std::invalid_argument(std::string(name) + " must be >= 0");
return v;
}
void invalidate() const { cache_area_m2_ = -1.0; }
public:
RectangleV2(double w_m, double h_m)
: w_m_(ensure_non_negative(w_m, "width"))
, h_m_(ensure_non_negative(h_m, "height")) {}
void setWidth(double w_m) { w_m_ = ensure_non_negative(w_m, "width"); invalidate(); }
void setHeight(double h_m) { h_m_ = ensure_non_negative(h_m, "height"); invalidate(); }
// 公開APIは変わらない(名前と意味を維持)
double width() const { return w_m_; }
double height() const { return h_m_; }
double area() const {
if (cache_area_m2_ < 0) cache_area_m2_ = w_m_ * h_m_;
return cache_area_m2_;
}
};
int main() {
RectangleV2 r{2.0, 3.5};
std::cout << "area(m^2)=" << r.area() << "\n";
r.setWidth(4.0);
std::cout << "area(m^2)=" << r.area() << "\n";
}
area(m^2)=7
area(m^2)=14
利用側はwidth()
, height()
, area()
を呼び続けられ、内部表現の変更を意識する必要はありません。
これがカプセル化によるAPI安定化の効果です。
アクセサ(getter/setter)の設計とデータ露出コントロール
ゲッター/セッターを安易に公開しない判断基準
ゲッター/セッターは「データをそのまま露出する」ため、意図を薄めがちです。
次を基準に公開を検討します。
- ドメイン上の操作に昇華できないか(例:個数を直接設定するのではなく
add()
/remove()
で意味を表現) - セッターは本当に必要か(不変オブジェクトや、一度だけ設定可能なプロパティにできないか)
- 値全体ではなく、問い合わせを提供できないか(例:
isEmpty()
,contains(x)
)
値/参照/ポインタの返し方とconstの使い分け
返却方法はコストと安全性のトレードオフです。
- 小さな値型(int, double, enum等)は値で返すのがシンプルです。
- 大きなオブジェクトは
const&
で返してコピーを避けられます。ただし、内部参照を外部に晒すと内部状態への依存が強まります。非const参照の公開は極力避けます。 - 文字列はC++17以降なら
std::string_view
の活用を検討できますが、ライフタイムに注意します(内部バッファが無効化されない設計にするか、値で返します)。
以下は、内部std::string
を安全に露出する例(値またはstring_view)です。
#include <string>
#include <string_view>
class Person {
private:
std::string name_;
public:
explicit Person(std::string name) : name_(std::move(name)) {}
// 値で返す(コピーコストはあるが安全)
std::string name_copy() const { return name_; }
// string_viewで返す(ライフタイム: このオブジェクトの生存中のみ有効)
std::string_view name_view() const { return name_; }
// セッターは必要最小限に
void rename(std::string new_name) { name_ = std::move(new_name); }
};
例外・検証・前提条件(precondition)の扱い
- 前提条件は受け付けメソッド側で検証し、破られたら早期に失敗させます。
- ランタイムでの不正は
std::invalid_argument
,std::out_of_range
等の例外、あるいは事前条件をドキュメント化してassert
で開発時検出する方針が有効です。 - 例外仕様はpublic APIの契約の一部として記述します。
#include <stdexcept>
#include <cassert>
#include <vector>
class SafeVector {
private:
std::vector<int> data_;
public:
void push(int v) {
// 制約例: 値は非負
if (v < 0) throw std::invalid_argument("v must be >= 0");
data_.push_back(v);
}
int at(size_t i) const {
assert(i < data_.size()); // 開発時チェック
return data_.at(i); // 実行時は範囲チェック付き
}
size_t size() const { return data_.size(); }
};
よくある誤解と落とし穴(public/private)
テストやシリアライズのためだけのpublic化を避ける
テスト容易性やシリアライズの都合でpublic化すると、設計が崩れます。
代替として、テストはpublic API経由で振る舞いを検証し、シリアライズは専用のアダプタやフレンド関数(最小限)で対応します。
あるいは保存形式に合わせたDTOを別で用意し、ドメインオブジェクトは堅牢に保ちます。
friendの乱用を避ける(必要最小限に)
friend
はprivateへの特権アクセスを与える強力な仕組みです。
乱用はカプセル化を損ねます。
代表的な正当な用途はoperator<<
などのユーティリティ実装に限定し、必要最小限とします。
#include <ostream>
#include <string>
class User {
private:
std::string id_;
public:
explicit User(std::string id) : id_(std::move(id)) {}
friend std::ostream& operator<<(std::ostream& os, const User& u) {
// 表示ポリシーをここに集約(必要最小限のfriend)
return os << "User(" << u.id_ << ")";
}
};
protectedとの混同を避ける:拡張点はpublic APIで用意
protected
は派生クラスにだけ見えるインターフェースですが、設計の自由度を下げ、拡張側の結合を強めます。
まずはpublic APIとして拡張点(フック、仮想関数、戦略の差し替え)を定義し、内部実装はprivateに留めるのが安全です。
継承よりも合成(composition)で拡張可能にする設計も有効です。
まとめ
publicは契約、privateは自由度です。
公開範囲を絞り、ドメインの意図を表すpublicメソッドだけを露出し、内部データと実装詳細はprivateに閉じ込めることで、不変条件を守りつつ変更に強いクラスが実現できます。
ゲッター/セッターは安易に増やさず、constで問い合わせを明確化し、検証・例外・ドキュメントで契約を固めましょう。
これらの基本的な使い分けが、カプセル化の力を最大化し、保守性・安全性・性能のバランスが取れたC++コードベースにつながります。