#前書き
本記事はOPTIMIND x Acompany Advent Calendar 2021の4日目の記事となります。
せっかくなので社内で実施する勉強会に使用する内容を記事にしようと思います。
#ことの始まり
ことの始まりは、より良いプログラミングを書く方法を探してネットの海に潜っている時のこと。
評価が高く良さそうな設計の本をいくつか発見、購入、実装を繰り返していた。
これがまぁ〜大体ほぼほぼ全部がオブジェクト指向をお勧めしてくるものばかり。まぁ実際これでいいんじゃねってくらいわかりやすい
いやいやいやちょっと待ってほしい、本当にオブジェクト指向以外のプログラム設計はわかりにくいのか?
そもそもオブジェクト指向以外の設計は手続き型と関数型をふんわり知っているくらいなので、ちゃんと否定も肯定もできない。
じゃあ調べるっきゃない!!
#プログラミングパラダイムとは
まず手始めにどのくらい種類があるのか調べるとプログラミングパラダイムというのに行き当たった。
プログラミングパラダイムとは、プログラミングにおける模範のこと。1
まぁつまりは『こんな感じでプログラミングを作っていきましょうね」という暗黙の設計、プログラムの書き方をそのように表すようだ。
当然だがそれぞれの手法に被りもあれば、真っ向から対立しているものもある。
う〜ん小難しいことが書いてあるが、どうも規約の絶対守りたい模範部分と絶対やらせたくない禁止事項さえ守っていれば大体OK!と思って良さそうだ。多分良くない
とりあえず、それぞれの設計手法を見ていこう。
以下サンプルプログラムは全てC++で記述する。理由は単純に好きな言語だからである。
#手続型プログラミング
みんな大好きC言語の手法。この手続型の手法はまとめるとこんな感じになる。
- 状態を保持する変数はグローバル
- 関数を分割して処理を短い関数にする
- 内部でローカル変数を使う(一時的な値置き)
一つずつサンプルを載せていく。
1.状態を保持する変数はグローバル
例
int counter = 0;
void countUp(){
counter++;
}
2.関数を分割して処理を短い関数にする
例
int counter = 0;
void countUp(){
counter++;
}
void countDoubleUp(){
countUp();
countUp();
}
3.内部でローカル変数を使う(一時的な値置き)
例
int i = 0;
void countUp(){
i++;
}
void countDoubleUp(){
countUp();
countUp();
}
void countStep(){
//カウントのステップ状態を保持
//関数実行時の状態を一時的に保持する
int step = 0;
countDoubleUp();
step++;
countUp();
step++;
}
こんな感じ?
外部からの使用時には関数を提供しているが、その際にも変更するのはグローバル変数に値を変更する形になる。
どうもデータというよりは細かな文操作(C言語でいうところのif,while,switch)の処理を出来る限りまとめているだけで、データそれ自体との共有にはそれほど気を使っていないように感じる。
ただグローバル変数に置くというのは、逆に言えば様々なところで変数が定義されていないとも考えられる。
例えばこのプログラムのように
//エラーの状態を保持する
int error = 0;
void checkOut(){
//エラーが発生しているかは上記の変数を確認すればわかる!!
if (error != 0){
}
}
void longlongTask(){
//...
//...
doBigTask();
otherBigTask();
checkError()
}
グローバル変数でエラーを確認しているので内部での状態は気にしなくても、特定の状態を並べれば細かな状態を把握できる。
(逆にどの変数に影響が出るか把握しづらいとも言えるがこれはしかたない)
つまりプログラム量が少ない場合で言えば、
必要最低限の状態を持った(グローバル変数+行いたい処理)操作で完了することができる。
- メリット
- 決まった変数を決まった処理で管理する場合は状態把握が非常に楽
- データの変更する処理はコストが少ない
- デメリット
- 関数で呼び出した副作用がどの変数に影響するのか信頼できない
組み込みや低レイヤで使われるのも理解できる。
グローバル変数とハードウェアの返す値を一対一対応することができるし、プログラム全体も(通常のソフトウェアに比べれば)短い物が多く、エラー等の状態異常の多さは群を抜いているだろうから検知も大事。
まぁ正直、
どちらかと言えばC言語が使われているからってのはあるだろうが理にかなってはいる。
#関数型プログラミング
ニッチな熱狂的ファンが多いイメージ。副作用がないプログラムというのが漠然と好きなので割と影響を受けている。
詳しくどのような物なのか調べていなかったのでまとめてみる。
- 式を基本として考える
-
絶対に!何がなんでも!どうあっても!副作用は出さない! (参照透過性)
以上!!
。。。これ冗談ではない圧力を感じる。
尚今回はモナドなどの話は詳しく書かないので悪しからず。
- 式を基本として考える
C言語等ではif,while,switchなどは文になっているので値を型に入れれないが、関数型言語を使用した場合それらに対応しているため式で評価できる。(関数型でなくてもifを式にしている言語は結構ある)
fn main(){
let num = 5;
let status = if num > 5{
1
}else{
2
};
}
そういえば条件演算子があった。これで一応表現できる。
int main(){
int num = 5;
int status = (num > 5)? 1 : 2;
}
これに関してはこの設計思想からは直接的な関係はないように思える。
- 絶対に!何がなんでも!どうあっても!副作用は出さない!
全部これに尽きる。参照透過性と言って関数を通る時に副作用がないので絶対に内部で完結する。要は、何度実行されても引数が同じなら同じ結果になると確約している。
これは先ほどの手続型を否定した使い方を想定している。
auto counter(int count){
return count + 1;
}
int main(){
int count = 0;
for(int i=0;i<10;++i){
count = counter(count);
}
//10までカウントアップ
}
。。何か絶望的にサンプルが悪い気がするが、とりあえずニュアンスが伝われば幸いです。
要は入力に対して変更を加えるなどはせず、決まった形を返す。
式だろうが関数だろうが決まった形を返す。
式を返す関数を返す関数だろうがとにかく返す。
そうすることで平和が訪れる。
みたいな感じかな?(ここら辺はモナドを詳しく調べると良い)
関数型は数式との親和性がとんでもなく高い。(というかそれらの用途で誕生したのか?)
以下簡単な数式を考えてみる。
f(x) = 2x + 5
これは正しく入力を変更せずに値を返す処理そのものと言える。
auto f(auto x){
return 2 * x + 5;
}
その言語を知らない人が見ても同じ数式が分かる書き方になっている。素晴らしい!!綺麗!!
しかし、副作用を持たないため出力データが巨大な場合は当然めちゃくちゃコピーのロスが大きい。
(ここら辺は言語とかコンパイラがうまくやってくれるので実は気にしなくても良い場合が多そう?)
- メリット
- 数式をプログラムに落とし込む時の負担がほぼゼロ
- 関数の副作用がない
- デメリット
- 出力するデータが大きいオブジェクトなどの場合にはそのままだとコピーの時間がかかる
数式でアルゴリズムを書き出すような感じで作業をしている時には、この書き方がベストと言える気がする。
とりあえず基本関数はこれでいい気がする。
#オブジェクト指向
王者。王道。これを考えずに何も語れない。
耳にタコができるほど聞いている内容だと思うので、説明を省くとこんな感じにまとめられると思う。
- データと操作用関数をまとめる(クラス、インスタンス)
- 関数を特定の時にしか呼べないようにする(カプセル化)
- クラスを単位として持ったり、それを拡張したりできる(コンポジション、継承)
- 抽象化したクラスを作れる(ポリモーフィズム)
**説明は全部省略**
要はデータを中心に考えるこれが全て。
データから処理を分割して考えた時点で間違っていると思って良い。(本当か?)
なぜこの手法がいいと言われているか、それはずばり大体の人間の扱う対象が物(オブジェクト)であるため、それと一対一の方が分かりやすいからだ。
当然扱うデータ、物(人、事、オブジェクト)とが密接になれば、動作と関数が近づくため、より理解しやすく直感的になる。
ただ他のパラダイムとちゃんと見比べてみると悪い点も見えてきた。
設計の変更が他に比べて難しくなっている!!
。。。
いやいや待て、設計しやすい手法として有名だからこんなに流行っているんだろう!
と思われた方はちょっと一緒に考えてみてほしい。
サンプルとして以下のような物を比較する。
こちらと
//部品
class Member1{
int status;
Member3 mem
public:
void doMethod(){mem.doMethod3();}
};
class Member2{
int check1;
public:
bool check(Member1){...}
void doMethod2(){}
};
//こんな感じのクラスがあるとする
class hardWare{
//メンバ変数などを保持して一纏めにする
Member1 men1;
Member2 men2;
//...
public:
//...
int do(){
men1.doMethod();
if (men2.check(men1) == true){
men2.doMethod2();
}
}
};
こちら
int status;
int check1;
int check2;
void doMethod1(){
doMethod3();
}
void doMethod2(){}
int do(){
doMethod1();
if (check(status,check1)){
doMethod3(check2);
}
}
実行されるプログラムは同じものになると考えるとする。
データと密接でない処理等を考えるときは普通にクラスが邪魔になっている。
確かに後々データが増えたり拡張が必要になる時に効果は発揮できるだろうが、現在の処理が全ての時はあえて行う必要はないのだ。
- メリット
- データとの対応が直感的で分かりやすい
- 拡張が容易
- デメリット
- データがないならメリットをあまり享受できない
- 記述量増えるし、設計が難しくなる場合もある
#調査まとめ
他のパラダイムも一通り調べたが、とりあえず自分の中で結論が出たのでまとめとする。
パラダイム名 | メリット | デメリット |
---|---|---|
手続型 | 変数の変更は楽 | 関数で何が変更されたか不明 |
関数型 | 関数の副作用はない | 出力データは基本コピー |
オブジェクト指向 | データとの親和性が無限 | データがないと弱い |
一体どれを使えば平和になるのだろうか。。
自分の出した結論は、使いたい時に使いたいパラダイムを使用すれば良い、ということ。
とは言っても基本的にはオブジェクト指向で考えて良いと思う。
基本はオブジェクト指向、メソッドとかは関数型、漏れ出た部分は手続型。
この形をプロジェクトメンバーができる限り守っていれば、プロジェクトの破綻は少なくなるでしょう。
(できれば苦労はないでしょうが。。)
よくよく考えれば、設計の良書と言われている物が勧めている手法はこれな気がする。
他にもパラダイムはあるが、何か使えそうな物があればどんどん取り入れても良いと思う。
プログラミング言語もマルチパラダイムを採用しているのだから、プログラマも合わせて行った方が良いということが分かった。
#結論
悪ではないが、基本的にはオブジェクト指向で良い。
他のパラダイムも良いところは使う。
みんな違ってみんな良い。