【C++】既存クラスを拡張する方法【拡張メソッド/カテゴリ】


C#の拡張メソッドやRubyのオープンクラス、Swiftのextension、Objective-Cのカテゴリーの活用例に近い感覚のものを実現させます。

既存のクラスにメソッド(メンバ関数)を追加する方法とグローバル関数(フリー関数)で代替する方法、演算子の多重定義を活用する方法の3種類の方法を紹介します。

オススメはフリー関数版の利用です。その他のイディオムは扱いが難しく、実用性には難があります。
スポンサーリンク

フリー関数

もっとも簡単で手軽な方法です。

フリー関数の第一引数にオブジェクトの参照を渡し、その参照値を元に関数内で独自処理を記述していきます。

void print(const std::string& s) {
   std::cout << s << std::endl;
}
void print(const std::vector<std::string>& v) {
   for (auto&& s : v) std::cout << s << ", ";
   std::cout << std::endl;
}

同一の名前空間で同名のprint関数が複数定義されていても問題はありません。C++の厳格な型検査と関数のオーバーロード解決の仕組みよって、型に応じた関数呼び出しが行われます。

以下はC++の文字列クラスを対象に拡張用の関数を複数定義した例です。

namespace string_extensions {
   std::string substrFrom(const std::string& s, std::string::size_type i) { return s.substr(i); }
   std::string substrTo(const std::string& s, std::string::size_type i) { return s.substr(0, i); }
   void print(const std::string& s) {
      std::cout << s << std::endl;
   }
}

使用例です。

std::string s = "1234";

string_extensions::print(string_extensions::substrFrom(s, 2));

using string_extensions::print;
print(string_extensions::substrTo(s, 2));
  
using namespace string_extensions;
print(substrFrom(s, 2)); // "34"
print(substrTo(s, 2));   // "12"

統一関数呼び出し記法が実用化された場合、将来的にメソッド呼び出し形式での利用が可能になる可能性があります。

print(std::string("s"));
std::string("s").print(); // 上記の呼び出し式と同等に解釈される
スポンサーリンク

演算子関数

既存クラス向けの演算子を多重定義し、オブジェクトに対して関数オブジェクトを適用するテクニックです。演算子によるメソッドチェーンも実現出来ます。

この発想は入出力ストリームの<<演算子の活用例と似たようなものだと思ってください。当然、演算子の優先順位や重複定義にも注意する必要があります。

// 演算子オーバーロード
template<class F> auto operator-(const std::string& s, F f) { return f(s); }

// 関数オブジェクト
struct substrFrom {
   std::string::size_type i;
   substrFrom(std::string::size_type i) : i(i) {}
   auto operator()(const std::string& s) { return s.substr(i); };
};
struct substrTo {
   std::string::size_type i;
   auto operator()(const std::string& s) { return s.substr(0, i); };
};
struct {
   const std::string& operator()(const std::string& s) {
      return std::cout << s << std::endl, s;
   };
} print;
std::string s = "1234";
s-print; // 出力: "1234"
// オペレータチェーン
s-substrFrom(1)-substrTo{2}-print; // 出力: "23"
// 単体利用も可能
print(substrTo{2}(s)); // 出力: "12"

メンバ関数

今回紹介する方法は通常の継承や別名クラスを用いた方法とは違い、クラス名や名前空間の名称を変える必要がないという特徴があります。

// ① Good!!
std::string str = "abc";
str.print();

// ② No good...
my::String str = "abc";
str.print();
StringUtils::print(str);

①のように既存のstd::stringクラスにprint関数を直接追加するイメージです。

名前空間を継承する

std等の既存の名前空間を継承し、その中でstringクラス等の既存クラスを継承することで、事実上のクラス拡張を実現させます。

namespace extensions {
   using namespace std; // 名前空間の継承を実現
   // ここで拡張したいクラスを継承していく
   struct string : std::string {
      using std::string::string; // ①
      // ここにメンバ関数を追加していく
   };
}

int main() {
   namespace std = extensions; // ②
   std::string s;  // 実体はextensions::string
   std::cout << s; // std::coutも使える
}

クラスのクローンを作るようなイメージですね。

① using std::string::string;

stringクラス内のusing std::string::string;は基底クラスのコンストラクタを継承するために必要です。これにより派生クラスに対して、基底クラスと同等の振る舞いをさせることができます。

② namespace std = extensions;

namespace std = extensions;は継承された拡張空間extensionsで既存のstdを上書きするために利用しています(正確にはエリアスとしてローカル宣言しているだけ)。

main関数の外(グローバル空間)では利用出来ませんが、名前空間内やメンバ関数内では利用可能です。

活用例

以下は実際にstd::stringクラスにprintメンバ関数を追加するサンプルコードです。

#include <iostream>
#include <string>

namespace string_extensions {
   using namespace std;
   
   struct string : std::string {
      using std::string::string;
      
      void print() {
         std::cout << *this << std::endl;
      }
   };
}

// clang++ -std=c++11 main.cpp
int main() {
   namespace std = string_extensions;
   std::string s = "abc";
   s.print(); // "abc"
}

間接的な利用テクニック

場合によっては、拡張クラスを戻り値にした場合に、暗黙の型変換によって自動的に基底クラスへのアップキャスト行われてしまう場合がありますが、autoで受け取ることでこれを回避出来ます。

namespace A {
   namespace std = string_extensions;
   std::string get() { return std::string("a"); }
}

auto get() { // C++14
   namespace std = string_extensions;
   return std::string("b");
}

int main() {
   auto a = A.get();
   a.print();     // "a"
   
   get().print(); // "b"
  
   std::string a = A::get();
   a.print(); // ERROR: No member named 'print' in 'std::__1::basic_string<char>'
}

これによって、拡張クラスを適用していない空間でも、拡張機能の間接的な利用が可能になります。

メンバ重複の対処とカテゴライズ

既存の機能にバリエーションを持たせる技法も合わせて紹介しておきます。同じメンバ名で異なる挙動を持たせる事が可能になります。

namespace string_abc {
   using namespace std;
   struct category_a : std::string {
      using std::string::string;
      const char* c_str() { return ("A: " + *this).c_str(); }
   };
   struct category_b : category_a {
      using category_a::category_a;
      const char* c_str() { return ("B: " + *this).c_str(); }
   };
   struct string : category_b {
      using category_b::category_b;
      const char* c_str() { return std::string::c_str(); }
   };
}

int main() {
   namespace std = string_abc;
   std::string s = "abc";
   std::cout << s.c_str();             // "abc"
   std::cout << s.category_a::c_str(); // "A: abc"
   std::cout << s.category_b::c_str(); // "B: abc"
}

考察

今回紹介したこの技法を用いると、stdに自作クラスを追加することが可能になってしまいます(あくまで仮想的・間接的ではありますが)。例えばstd::jsonstd::string_viewという機能を独自に追加出来てしまうわけです

C++の規格上では名前空間stdに特殊化以外の用途で独自の定義を加えることが禁止されているのですが(17.4.3.1)、今回の方法であれば、おそらくその制限には抵触しない形になるのではないかと思われます。

そもそも名前空間のエイリアスにstdを使っていいのかという疑問も残ります。また常識の範囲内で考えても良いイディオムではないかもしれません。

備考

ちなみに将来的にa.fn(b)式をfn(a, b)式と解釈させる仕様がC++に取り込まれた場合、今回紹介したテクニックは不要になり、代わりに以下のような書き方で、既存クラスの拡張が実現出来るようになります。

void print(const std::string& s) { std::cout << s; }

// printメンバ関数が見つからない場合、
std::string("a").print();
// 代わりにこちらの関数呼び出しが行われる
print(std::string("a"));

この未来、信じるか信じないかはあなた次第。

実用性

今回紹介した技法はかなりアクロバティックな技法であり賛否両論があるかと思います。個人的には移植性の問題を気にしなければ問題なく使えるレベルのものだと感じています。

他にも注意しなければならない問題は色々あるのですが、その点については後半でじっくり解説します。

デバッグ用途での使用がオススメ

ちなみに、この技法はもともとstd::vector::operator[]演算子の境界チェクを実現するために生み出したものなので、デバッグ用途で使うのが本来もっともオススメの活用方法です。

// 境界チェック付きvector
namespace std_vector_bounds_checking {
   using namespace std;
   template<class T, class A = std::allocator<T>>
   struct vector : std::vector<T, A> {
      using std::vector<T, A>::vector;
      typename std::vector<T>::reference
      operator[](typename std::vector<T>::size_type n) {
         return this->at(n);
      }
   };
}
#ifdef DEBUG
namespace std = std_vector_bounds_checking;
#endif

// デバッグ環境
std::vector<int>()[0]; // 例外発生(バグの早期発見に繋げる)

// リリース環境  (ハードリアルタイムシステム)
std::vector<int>()[0]; // チェック無し(パフォーマンス優先)

// 良くないやり方
// namespace std { class debug_vector : std::vector; }
// #define vector debug_vector; // 更に良くないやり方
// std::vector<int>().at(0);    // 後々面倒なやり方

こうすることで、境界チェック(範囲チェック)用のクラスを別名で作る必要が無くなります。またリリースビルド前のコード修正や、下手なマクロ置換も不要になります。

実用化は難しい

stdを再定義する際に一つの名前空間しか指定出来ないため、モジュール化や個々の機能のカテゴライズが困難だという実用面の問題が残っています。

また用途によっては、多態性の仕組みや非virtualデストラクタの問題をきちんと理解した上で使わなければなりません。

リソースの開放に注意

今回の技法は結局のところクラスの継承を用いているため、「非仮想デストラクタ」問題の影響を受けます。つまるところ、アップキャスト後のポインタを開放した場合、派生クラス側のデストラクタが呼ばれなくなる問題に遭遇します。

そのため、リソース開放が必要な処理には注意する必要があります。

ダウンキャスト・アップキャストの問題

C++では基底クラスから派生クラスへの暗黙的なキャストが行われないため、手動でのキャストを行う必要が出てきます。よって、他のAPIとのやり取りがしづらくなるという欠点があります。

公開インターフェースの問題

公開インターフェースに関する問題は以下の方法で対処出来ます。また拡張機能の利用を隠蔽することも可能になります(つまりmain.cpp側は拡張クラスやキャスト処理を意識する必要がなくなる)

/* extensions.hpp */
namespace std_neue {
   using namespace std;
   namespace super = std; // 重要ポイント
}
namespace std_neue {
   struct string : std::string { using std::string::string; };
}

/* app.hpp */
namespace app {
   void exe(std::string str);
}
/* app.cpp */
#include "extensions.hpp"
namespace app {
   namespace std = std_neue;
   void exe(std::super::string str) { // 重要ポイント
      std::string s = str; // 必要に応じてキャスト
  }
}

/* main.cpp */
#include "app.hpp"
// #include "extensions.hpp" // 不要
int main() {
  // namespace std = std_neue; // 不要
  std::string str = "str";
  app::exe(str); // キャスト不要(app.cpp側で解決)
}

派生クラスの問題への対処

内部的な問題に関しては、コピー・コンストラクタを手動で宣言することで対処が可能ですがオススメはしません。

struct string : std::string { using std::string::string;
   string() = default;
   string(const std::string& s) : std::string(s) {}
   string(std::string&& s) : std::string(std::move(s)) {}
   void test_gsub() {
      std::string s = std::regex_replace("a", std::regex("a"), "b");
      string r = s;
   }
};

そもそもそこまでして使いたいと思えるイディオムでもないでしょう。本イディオムは、もともとデバッグ用のテクニックとして生み出され、関数内やブロック内等で局所的に利用されることを想定しています。あくまで「簡易的な機能」として割り切って使われるべき物です。

本来は既存のAPIをフックして不具合の原因特定を行うために活用されてきた経緯があります。

カテゴライズが困難

今回はprint関数用のstring拡張を例に仮想空間string_extensionsを作成しましたが、今後「正規表現」用の拡張も追加したいとなった場合は、同じようにstring_extensions::stringへ直接、正規表現関連のメンバ関数を追加する必要が出てきます。

本来であれば、string_printer.hppstring_regex.hppというヘッダーファイルを作成し、必要に応じて両者をインポートしたり、組み合わせて利用できるようにしたいのですが、現状の言語仕様ではその手の実現が困難です。

プリプロセッサを用いれば出来なくもないのですが、それはあくまで最終手段です。

ちなみにnamespace string_printer { using namespace string_regex; }という入れ子状の宣言方法を用いれば、ファイル分割自体は可能になりますが、両者の間に依存関係が形成されてしまいます。結局のところ、モジュール化という本来の目的は果たせません。

クラス別のモジュール化が困難

また同様に、stringクラス用の拡張とvectorクラス用の拡張を分離するといったことも困難です。

#include "string_extensions.hpp"
#include "vector_extensions.hpp"
namespace modules {
   // このように必要な物のみ取り込めるようにしたい
   using namespace string_extensions;
   using namespace vector_extensions;
}

int main() {
   namespace std = modules;
   // Name hidingの影響を受けてエラーになる
   // A type named 'string' is hidden by a declaration in a different namespace
   std::string x;
}

対応策としては、以下のような複数の方法が考えられます。

コード単位でモジュール化

パッケージ管理や保守、単体テストがしづらくなる問題があります。そのため、場合によってはプリプロセッサの利用が必要になるかもしれません。

/* string_extensions.hpp */
// namespace string_extensions {
struct string : std::string { using std::string::string; };
// }

/* vector_extensions.hpp */
// namespace vector_extensions {
template<class T> struct vector : std::vector<T> { using std::vector<T>::vector; };
// }

/* main.cpp */
namespace marge {
   using namespace std;
   #include "string_extensions.hpp"
   #include "vector_extensions.hpp"
}
int main() {
   namespace std = marge;
}

共通の名前空間を決め打ちしてモジュール化

おそらく最も現実的な妥協案です。

/* string_extensions.hpp */
namespace std_neue { using namespace std;
   struct string : std::string { using std::string::string; };
}
/* vector_extensions.hpp */
namespace std_neue { using namespace std;
   template<class T>struct vector : std::vector<T> { using std::vector<T>::vector; };
}

/* main.cpp */
#include "string_extensions.hpp"
#include "vector_extensions.hpp"
int main() {
   namespace std = std_neue;
}

現実的とは言っても問題は残ります。

柔軟性に乏しいという問題

固定的な名前を使っているため、自作した拡張名前空間の内の一部の機能だけを、第三者の物とすり替えるようなことが出来なくなります。他人のライブラリでも同様にstd_neueという名前空間が使われていれば、この限りではありませんが・・・。

この問題に対処するためには先程の# コード単位でモジュール化する方法を採る必要が出てきます。

名前空間の侵食問題

またstring拡張とvector拡張で共通の名前空間を使っているため、「片方の拡張を使うと、もう一方の拡張も取り込まれてしまう」という問題もあります。

#include "string_extensions.hpp"
#include "vector_extensions.hpp"
namespace A {
   namespace std = std_neue;
   // string拡張のみ使いたいがvector拡張も取り込まれてしまう
}
namespace B {
   namespace std = std_neue;
   // vector拡張のみ使いたいがstring拡張も取り込まれてしまう
}

ヘッダ側がリンケージを持たず、また多重インクルードを容認する前提であれば、以下の方法での対処も可能です。あくまで例外的な活用法であることに注意してください。

namespace A {
   #include "string_extensions.hpp"
   namespace std = std_neue;
   // string拡張のみ取り込まれる
}
namespace B {
   #include "vector_extensions.hpp"
   namespace std = std_neue;
   // vector拡張のみ取り込まれる
}

結局のところ実用的ではない

以上のことから、本イディオムには、保守性とユーザビリティを担保するための最善の策が存在しないという重大な問題があります。C++の今後の進化によっては本イディオムが実用化できる日がくるかもしれません。

そもそも既存クラスの拡張はきちんと言語機能としてサポートして欲しい気もします。

Unified Call Syntax

その点を考えると、先程紹介したa.fn(b)式とfn(a, b)式の案がもっとも現実的な気がしますが、本仕様の提案はだいぶ前から行われており、いまだ実現には至っていないようです(N4165, N4474)。

既存のC言語APIも扱いやすくなるので、個人的には面白い仕様だと感じています。オートコンプリートの恩恵も受けられるようになります。

const char* cstr = "hello";
cstr->strcmp("hello"); // 0
cstr->strlen();        // 5

// ただこのケースにどう対応するのかという問題が残る
// char* strcat(char*, const char*);
cstr->strcat("world"); // ???

namespace template

余談ですが、もし仮に名前空間でテンプレート機能が利用出来るようになれば、以下のように、利用シーンに応じたモジュール選択が可能になるかもしれません。

// 出力モジュールと正規表現モジュールを追加
namespace std = extension_string_print<extension_string_regex<std>>;
// 正規表現のみ追加
namespace std = extension_string_regex<std>;

活用サンプル

template<namespace M> namespace extension_string_print {
   using namespace M;
   struct string : M::string {
      using M::string::string;
      void print() { M::cout << *this << M::endl; }
   };
}

template<namespace M> namespace extension_string_regex {
   using namespace M;
   struct string : M::string {
      using M::string::string;
      string(string&& s) : M::string(M::move(s)) {}
      string gsub(string p, string r) {
         return (string&&)M::regex_replace(*this, M::regex(p), r);
      }
   };
}

namespace std = extension_string_print<extension_string_regex<std>>;

int main() {
   std::string s = "abc";
   s.print(); // "abc"
   std::cout << s.gsub("b", "*"); // "a*c"
   
   string("abc").gsub("\\w", "$0,").print(); // "a,b,c,"
}

このように必要な拡張をチェーン状に繋げていくイメージですね。 モジュールの取り回しや管理はしやすくなりそうです。

実験

namespace std = 
   extension_string_regex<
   extension_string_base64<
   extension_vector_json<
   extension_map_json<
   extension_map_xml<
std>>>>;

えげつないですね。

namespace std
   = extension_string_regex
   < extension_string_base64
   < extension_vector_json
   < extension_map_xml
   < std
>>>>;
template<namespace M> namespace extensions_string =
   extension_string_print < extension_string_regex <
   extension_string_view  < extension_string_ref   <
M>>>>;

template<namespace M> namespace extensions_vector = 
   extension_vector_print < extension_vector_json <M>>;

namespace std = extensions_string<extensions_vector<std>>;

いずれにしろ、namespace templateが実現されれば、先程挙げた様々な問題が一気に解消されます。

広告