この記事は CAMPHOR- Advent Calendar 2023 15日目の記事です
TL;DR
Cでプログラミングを始めた皆さん
線型方程式のソルバの実装を通じてC++への一歩を踏み出してみませんか
導入
C言語でプログラミングデビューした学生1の皆さん、
次の一歩を踏み出しあぐねているのではないでしょうか?
今キャッチーな(お金になる)言語といえばPython, Go, TypeScript辺りが挙げられるでしょうか?
Rustも幅広く注目を集めていますね
やりたいことがはっきりしている方は、それに応じた道を進めばいいと思います。
でも、ぶっちゃけやりたいことって、見つからないよね〜〜
そんな方へ向けた記事です。
数値計算なら、理工系の学生さんには馴染みが深いテーマだと思います。
数値計算、具体的には線形代数方程式のソルバの実装を通じて、
プログラマとしての次の一歩を踏み出しましょー!、という記事です。
注意
この記事のコードを勝手に使うぶんには構いませんが、
大学などの課題にそのまま利用するのはやめましょう。
結構な確率で怒られます2。不正行為は、ダメ、ゼッタイ
C++ってな〜に?3
雑にいうと、C言語のスーパーセットです。
C言語と同様にコードを書いて、コンパイラ・リンカを用いてバイナリを得ます。
予約語などを除けば、C言語で記述された任意のコードは、C++コンパイラを通ります(例外有り)4
では、C言語がどのように拡張されているのか?
一番目につくのはクラスですね。オブジェクト指向5って、聞いたことありますよね?
他にもテンプレートや名前空間など、結構便利な概念が増えています。
しかし、根っこはC言語なので、生ポインタを扱うことができます。
スマートポインタなど、ポインタ周りのバグを防ぐ仕組みもあるのですが、
生ポインタを触れる、安全とは限らない言語という認識は持っておいてくださいね。
また、オブジェクト指向になって便利になった側面も大きいのですが、その代償として
よくよく考えて実装しないと、結構遅くなってしまう
という弱点もできています6。
また、言語仕様が恐ろしく複雑です。度々標準化がなされています。
書くことは比較的簡単にできても、他人が書いたコードを読むのは恐ろしく大変なこともしばしば、
そんな言語です。
そんな、C言語の弱点を若干引き継ぎ、長所は若干失ってしまったC++7ですが、
書きやすいためなのか何なのか、結構いろんなところで使われています8。
使えると何かと便利ですよ!
C++の書き方は基本的にCと変わらないため、ここではC++入門的なコードは示しません。
不安な人のための補足を以下に貼っておきます。
補足: C++のHello World
#include <iostream>
#include <sstream>
int main() {
std::ostringstream str_out;
str_out << "Hello" << " " << "World!" << std::flush;
std::cout << str_out.str() << std::endl;
return 0;
}
何やらシフト演算子が大量に出ていますね。C++で文字を扱う際によく見られる光景です。
これは、文字列をより簡単に扱うことができる、文字列ストリームという仕組みを使っています。
str_outという変数に"Hello"や"World!"などの文字列が流し込まれているのがわかると思います。
ライブラリ側でバッファを用意し、管理することで文字列が扱いやすくなっているのです。
一方、使い方が悪いとバッファの再アロケーションが頻発して、性能が落ちてしまうのですが…
なお、シフト演算子をこのような用途に使える理由はもう少し後で説明します。
さらに、std::coutにstr_outを文字列にして流し込んでいます。
そうです。端末上の入出力も文字列ストリームの仕組みを使って操作することができるんです。
なお、入力は以下のように受け取ります。ファイル入出力も同様にできます。
std::cin >> str_in;
ちなみに、std::cinなどの"::"を用いた表現は名前空間と呼ばれるものです。
大規模なプロジェクトを開発していると、同じ名前を複数の変数につけてしまうことがあります(もちろんバグる)。
この現象を名前の衝突といいます(ライブラリを利用していたりするとよくある)。
名前空間とは、名前の衝突を防ぐための仕組みで、大規模なプロジェクトやライブラリの開発などで特に用いられます。
ひとまずは、標準ライブラリで定義されているものを使うときは基本的に頭に"std::"をつける、と思っておいてください。
このように、C++はCとは似たような言語ですが、様々な機能が追加されていて、結構様相が異なる言語になっています。
今回の内容
ズバリ、有理数型を通じて、クラスに慣れよう!です。
クラスとは、すごく雑にいうと、構造体がメンバとして関数を持つようになったものです9。
有理数型を実装して、そのメンバ関数や演算子のオーバーライドなど、C++がもつクラスまわりの機能に慣れていきましょう。
クラスはC++に限らず、現在人気の言語のほとんど10に搭載されている仕組みなので、ここで是非感覚を掴みましょう。
有理数型を作ってみよう
まずは、有理数型を例にクラスを定義し、クラスなどの仕組みに慣れていきましょう。
有理数は、整数a,bを用いて a/bと表せる数です。
ですので、今回実装する有理数型では、分母・分子の値を保持します。
intでなくlongを用いているのは、数値計算の講義の課題で書いた名残です。
intでも基本的に問題はありません。
データメンバの実装
早速、有理数のデータ型を定義してみましょう。
#include <iostream>
class rational{
public:
long num;
long den;
};
int main(){
rational a = {1,3};
// a := 1/3
std::cout << a.num << "/" << a.den << std::endl;
// > 1/3
a.num = 2;
std::cout << a.num << "/" << a.den << std::endl;
// > 2/3
return 0;
}
冒頭で中身を定義しているのがクラスです。
そのあと定義されているaをインスタンス、だとかオブジェクト、と呼びます。
クラスが設計図であり、そこから生み出されるのがインスタンスだと思ってください。
rationalというクラスでは、データメンバとして分子(num)、分母(den)を定義しました。
データメンバしか定義しない場合、C++のクラスはC言語の構造体とはほぼ変わりません。
2行目のpublicに関しては後で詳細を述べます。
メンバ関数の実装
続いて、メンバ関数を実装していきます。
メンバ関数とは、クラスに付随する関数です。
#include <iostream>
class rational{
public:
long num;
long den;
double to_double(){
return (double)num / (double)den;
}
void add_int(int a){
num += den * a;
return ;
}
};
int main(){
rational a = {1,3};
// a := 1/3
std::cout << a.num << "/" << a.den << std::endl;
// > 1/3
std::cout << a.to_double() << std::endl;
// > 0.333333
a.add_int(2);
std::cout << a.num << "/" << a.den << std::endl;
// > 7/3
return 0;
}
クラスの宣言内にvalとadd_intなる関数が追加されていますね。
こういった関数をメンバ関数といいます。
メンバ関数は、データメンバと同様にアクセスすることができます。
ここで定義されているto_doule関数は有理数の値をdoubleで返す関数です。
add_int関数は、有理数に整数を足すコードです。
メンバ関数内では、クラス自身のデータメンバはそのままアクセスできます。
実際、これらの関数内で分子や分母はnum,denとアクセスされています。
実際にはthis->num, this->denなどと書くところを省略しています。
オブジェクト内では、thisというポインタがオブジェクト自身を指すものとして暗黙的に定義されていて、それにアロー演算子を使っている、ということになります11。
thisを使ったほうが可読性は上がるため、慣れれば積極的に使ったほうが良いでしょう。
また、ご覧の通りメンバ関数は呼び出し時に引数を受け取ることもできます。
有理数型を使いやすくしよう
ここまで有理数型を定義しましたが、まだまだ使いやすいものとは言い難いです。
何らかの理由で分母が0になってしまう可能性がある実装なので、いずれバグを誘発するかもしれません。
また、有理数を分母と分子で保持するのなら、既約な形で保持したいですよね。
さらにいうと、有理数型の値どうしで四則演算をできるようにしたいです。
この辺りを目標に、有理数型をより良いものにしていきましょう。
コンストラクタ
コンストラクタとは、インスタンスをつくる際に実行されるコードです。
C言語の構造体では、メモリ領域を確保したり、データメンバを初期化する程度のことしかできませんが、コンストラクタではもっと様々なことができます。
では、コンストラクタを用いて有理数型をより堅牢なものにしてみましょう。
class rational{
long num;
long den;
public:
rational(long num_, long den_){
if(den_ == 0)
throw "Err: denominator cannot be 0\n";
long gcm_ = gcm(num_,den_);
if(den_ > 0){
num = num_ / gcm_;
den = den_ / gcm_;
} else {
num = - num_ / gcm_;
den = - den_ / gcm_;
}
}
rational(long n = 0){
num = n;
den = 1;
}
~rational(){}
double to_double(){
return (double)num / (double)den;
}
};
/*--------------------------------------------
--------------------------------------------*/
auto a = rational(1,3);
// a := 1/3
// rational a = rational(1,3); でも可
std::cout << a.to_double() << std::endl;
// 0.333333
auto b = rational(3);
// b := 3/1
auto c = rational(1,0);
// >Err
a.num = 2;
// >コンパイルエラー
今回の有理数型にコンストラクタを実装すると、こんな具合になります。
コンストラクタは、クラス名と同じ名前のメンバ関数として定義されます。
また、引数が違う複数の関数をコンストラクタとして定義することもできます12。
ちなみに、コンストラクタを呼ぶときにautoというキーワードを使っています。
これは、何らかの変数を初期化するときに使うと、コンパイラが型を推測してくれます。
rational a = rational(1,3);としても良いのですが、表現が冗長になりますからね。
ただ、濫用すると非常に見つけにくいバグを引き起こしたりするので要注意
1つ目のコンストラクタを見てみてください。
分子、分母の2つの引数を受け取るコンストラクタです。
分母が0のときは例外を投げています13。
また、このコンストラクタではgcmという関数を呼んでいます。
その名の通り最大公約数を求める関数として実装しています14。
分子と分母の最大公約数を求め、約分したうえで値を保持しています。
コンストラクタでは、このようにデータメンバの値を適切に初期化したり、エラーを投げる処理がなされることが多いです。
一方で、メモリを動的に利用し、ポインタを保持するクラスではコンストラクタでメモリ領域の確保を行ったり、共有データの所有権の管理を行ったりすることも多いです。
また、コンストラクタと対をなす、デストラクタという関数も定義できます15。これは、~(クラスの名前)という形で定義できます。
余談ですが、2つ目のコンストラクタを見てください。これは整数で値を初期化するためのコンストラクタですが、引数の記述に違和感がありませんか?
rational(long n = 0){略}
n = 0と書いてあります。
これは、引数のデフォルトの値で、C++で追加された機能の一つです。
関数の定義の際にこうして何らかの値を指定しておくと、関数の呼び出しの際に引数を省略することができます。
なお、デフォルトの値を設定した引数よりあとに、デフォルトの値を設定しない引数を定義することはできないことになっています。気をつけましょう。func(int a, int b = 0) //OK
func(int a = 0, int b) //NG
こうしてコンストラクタでは異常な値を弾き、約分された値を保持できるようになっていますが、それだけではバグはなくなりません。
インスタンスを作ってからnumやdenを書き換えてしまえば、コンストラクタで0を除外したり、約分した意味がなくなってしまいます。
そこで、今回のrationalクラスでは、分子・分母に外部からアクセスできなくなっています。
先程意味深に出てきたpublic:というキーワードが鍵です。
これは、クラスのデータメンバやメンバ関数に、外部からアクセスできるようにするためのものです。
クラスの定義は、以下のような形になっています。
class hoge{
//ここに書いてあるメンバはprivateの扱いになる
public:
//コンストラクタや外部アクセスを許可するメンバを書く
hoge(){}
int a;
void method(){}
private:
//外部アクセスを許可しないメンバを書く
};
public:に続いて定義されるメンバは外部からアクセスできるのに対し、private:に続いて定義されるメンバは内部からしかアクセスすることができなくなります。
そのため、外部から変更されたくないデータメンバや、外部から叩かれたくないメンバ関数はprivate:のあとに定義することになります。
なお、privateなメンバに対しては、publicなメンバを通じて適切な形で操作を行うことになります。
今回だとnum,denなどのprivateなデータメンバはpublicなコンストラクタを通じて制限された形で設定し、to_doubleなどのpublicなメンバ関数を通じてアクセスすることになります。
なお、classの場合はデフォルトでプライベートメンバ(外部からアクセスできない)として定義されるため、今回public:の前に定義されたnum,denはプライベートメンバになるわけです16。
演算子のオーバーロード
さて、今度は+や-などの演算子を使って、有理数型のインスタンス同士で演算を行えるようにしましょう。
オーバーロードとは、引数が異なる同名の関数などを定義することです。
C言語ではオーバーロードをするとコンパイルエラーになりましたが、先程のコンストラクタが複数定義できたようにC++ではオーバーロードが許されています。
さらに、C++では演算子もオーバーロードすることができます17。
具体的に見てみましょう。
class rational{
long num;
long den;
public:
rational(long num_, long den_){
if(den == 0)
throw "Err: denominator cannot be 0\n";
long gcm_ = gcm(num_,den_);
if(den > 0){
num = num_ / gcm_;
den = den_ / gcm_;
} else {
num = - num_ / gcm_;
den = - den_ / gcm_;
}
}
rational(long n = 0){
num = n;
den = 1;
}
~rational(){}
double to_double(){
return (double)num / (double)den;
}
rational operator - (){
return rational(-(this->num), this->den);
}
bool operator == (rational obj){
return (num == obj.num) && (den == obj.den);
}
rational operator + (rational obj){
long gcm_d = gcm(den, obj.den);
long den_ = den * (obj.den / gcm_d);
long num_ = num * (obj.den / gcm_d) + obj.num * (den / gcm_d);
long gcm_ = gcm(den,num);
return rational(num_/gcm_, den_/gcm_);
}
};
/*--------------------------------------------
--------------------------------------------*/
auto a = rational(1,3);
auto b = -a;
// b := -a
auto c = rational(1,2);
auto d = b + c;
// d = 1/6
auto e = rational(1,6);
std::cout << (d == e) << std::endl;
//>1(true)
このように、自作のクラスなどに単項演算子や2項演算子を実装することができます。
単項演算子に関しては簡単です。
関数名の代わりにoperator (演算子)とすればあとは関数と同様に定義できます。
2項演算子の場合、話は若干ややこしくなります。
ここでは演算子をメンバ関数として定義していますが、どちらか一方は引数として受け取っています。
これは直感的に受け入れてもらえると良いんですが、2項演算子は左の項のメンバ関数として定義されます。
右の項が引数です。
あとは普通のメンバ関数と同様に書けば問題ないです。
最後に
さて、ここまで有理数型を実装してきました。
この過程でクラスの概念に慣れて貰えれば幸いです。
次回はこの有理数型を使って線型方程式の解を求めるライブラリを実装してみましょう。
テンプレート、名前空間などの概念を扱う予定です。
なお、ここまでで一部の演算子しかオーバーロードしていませんが、線型方程式を解くには引き算や掛け算、割り算も実装する必要があります。
初心者の方は是非一度手を動かして実装してみてください。
補遺 最大公約数を求めるコード
ただのユークリッドの互助法です。
手元で実装していてバグに困った際などにお使いください
long gcm(long a, long b){
if(a < 0) a = -a;
if(b < 0) b = -b;
if(a == 0)
return b;
if(b == 0)
return a;
if(a == b)
return a;
if(a > b){
while(a > b)
a = a - b;
return gcm(a,b);
}
else{
while(b > a)
b = b - a;
return gcm(a,b);
}
};
-
ストイックな工学系に多い印象、FORTRAN? 知らない子ですね。 ↩
-
というかこのブログは私が自身の課題のために書いたコードをほぼ流用しています。私まで怒られかねないのでやめてね、ほんとに。 ↩
-
厳密な話や歴史の話、哲学の話などはしません(というか知らない)。 ↩
-
これはご指摘を頂いて気づいたのですが、ポインタの暗黙的キャストなどがC++だと制限が厳しくなっています。筆者の肌感覚として、CのコードをC++でコンパイルしてコケるのは、だいたいこれか、ライブラリ周りの問題だと思います。 ↩
-
C言語でも関数ポインタとか使えばオブジェクト指向できるだろ、とかいうガチプロの皆さん、ご容赦ください。初心者の方が震えています。 ↩
-
C言語でも当然遅いコードは書けますが、C++の場合、見た目がほぼ同じなのに実行時間がまるで違う関数などがもりもり出てきて、高速化にはそれなりの知識が要るようになっています。 ↩
-
そんなわけで、C++はC言語の上位互換といえる存在かというと微妙なわけです。C++はベターCではない、としばしば言われるのはそういう事情です。 ↩
-
低レイヤ、システムプログラミング、組み込み系、ロボットなどがよく挙げられます。また、unreal engineなどの大規模なシステムの開発にもC++が用いられていたりします。 ↩
-
より詳しく知りたい人は終わってからもう少し詳しい本を読もう。 ↩
-
私の感想です。私がクラスがないC以外のメジャーな言語をほとんど知らないだけともいう。 ↩
-
この辺りの仕様は言語によって変わってきます。例えばpythonの場合はself.denのようにアクセスします。 ↩
-
C++では、引数の異なる同名の関数を複数定義することができます。これを関数のオーバーロードといいます。 ↩
-
C++ではこのように、throwというキーワードを用いて例外を投げることができます。エラーの拾い方は今後余裕があれば触れます。 ↩
-
gcm関数の実装は重要ではないので最後に載せます。 ↩
-
クラスが動的にメモリを確保するような(≃ポインタを叩く)場合は、インスタンスを破棄する際に後始末を行う必要があります(mallocに対応するfree)。 ↩
-
余談ですが、C++にもstructという概念は出てきますが、classとほぼ同じものです。classの場合デフォルトでプライベートメンバになりますが、structの場合はパブリックメンバになります。 ↩
-
補足の項で、ストリームを流す際にシフト演算子を用いていることについて触れましたが、これはストリームに対してシフト演算子をオーバーロードした実装が用意されているためです。 ↩