🙌

【CleanArchitecture】読書メモ 3つのプログラミングパラダイム

2023/10/10に公開

CleanArchitectureを読んだ理解メモ

構造化プログラミング

  • go to文はプログラムの構造に対して有害なのでif/then/else/do/while/untilのような構文に置き換えられた
  • 構造化プログラミングはモジュールを証明可能な単位に再帰的に分割することを可能にする
  • 分割された機能は、構造化プログラミングの制限された制御構造を使って表現することができる
  • 数学は証明可能な主張を真であると証明する学問であり、科学は証明可能な主張を偽であると証明する学問である
  • テストはそのプログラムが正しいということを証明することはできず、正しくないことは証明できる
  • ソフトウェア開発はむしろ科学のようなもの
  • 「正しくないことの証明」は証明可能なプログラムにしか適用できない
  • 構造化プログラミングの価値を高めるのは、反証可能なプログラミング単位を作成する能力
  • アーキテクチャレベルにおいて機能分割がベストプラクティスだと考えられているので現代の言語にはgo to文がサポートされていない
  • ソフトウェアアーキテクトは簡単に反証(テスト)できるモジュール、コンポーネント、サービスを定義しようとする

オブジェクト指向プログラミング

オブジェクト指向をカプセル化、継承、ポリモーフィズムで説明しようとする人がいる。それを順番に見ていく。

カプセル化

  • カプセル化がOOの定義の一部となっているのは、OO言語が、データと関数のカプセル化を簡単かつ効果的なものにしており、それによってデータと関数の周囲に線を引くことができ、外側にはデータは見えないようになっている。
  • しかしこの考え方はOO言語だけで実現できるというわけでなくC言語でも実現できている。
  • C言語はオブジェクト指向プログラミング言語ではないが、データの隠蔽を目的として下記のような方法でメンバ変数を外部から隠蔽していた

point.h

//メンバ変数をヘッダファイルで宣言しない
struct Point; 
struct Point* makePoint(double x, double y); 
double distance(struct Point *p1, structPoint* p2);

※ヘッダーファイルは宣言してコンパイルに何が存在をするかを伝えるファイル

point.c

#include" point.h"
#include <stdlib.h>
#include <math.h>
struct Point { double x, y;};
struct Point* makepoint(double x, double y){
struct Point* p = malloc(sizeof(structPoint));
  p->x = x;
  p->y = y;
  return p;
}
double distance(struct Point* p1,struct Point* p2){
    double dx = p1->x - p2->x;
    double dy = p1->y - p2->y;
    return sqrt(dx*dx+dy*dy);
  }

※プログラムの中で何が具体的に実装されてるか、変数の場合はどのようにメモリが確保されてるか定義してる

  • point.hのユーザーはstruct Pointのメンバーにアクセスできない
  • makepointやdistanceにはアクセスできるがPointのデータ構造や関数の実行については何も知らない
    • アクセスしたいならsetterやgetterなど作成し、公開された関数からのみアクセスできるようになる
  • 完璧なカプセル化
  • これらがC++のコンパイラの都合で.hファイルにメンバ変数を宣言する必要が出てきてカプセル化が壊されてしまった
  • JavaやC#ではヘッダファイルを廃止し、さらにカプセル化が弱体した
  • OO言語を提供すると主張してきた言語はカプセル化を弱体化させてしまっている。

継承

  • 継承とはスコープ内の変数と関数のグループを再宣言したもの

main.h

#include "point.h"
#include "namedPoint.h"
#include <stdio.h>
int main(intac,char**av){
    struct NamedPoint* origin = makeNamedPoint(0.0, 0.0, "origin");
    struct NamedPoint* upperRight = makeNamedPoint(1.0, 1.0, "upperRight");
    printf("
        distance = %f\n",
        distance(
            (struct Point*)origin,
            (struct Point*)upperRight
            )
        );
    }

namedPoint.h

struct NamedPoint;
struct NamedPoint* makeNamedPoint(double x, double y, char* name);
void setName(struct NamedPoint* np, char* name);
char* getName(struct NamedPoint* np);

namedPoint.c

#include "namedPoint.h"
#include <stdlib.h>
struct NamedPoint{double x, y;
    char*name;
};
struct NamedPoint* makeNamedPoint(double x,double y, char* name){
    struct NamedPoint* p = malloc(sizeof(struct NamedPoint)
);
    p->x = x;
    p->y = y;
    p->name = name;
    return p;
}
void setName(struct NamedPoint* np, char* name){
    np->name = name;
}
char* getName(struct NamedPoint* np){
    return n
}
  • main.cを見ると、NamedPointというデータ構造がPointの派生物のように動作している
  • NamedPointと最初の2つのフィールドの順序がPointと同じ順番を保持しているのでPointになりすますことができている
    • →データ構造の成りすましは便利になった
  • しかしこのトリックを使っての多重継承は難しく、NamedPointの引数をPointにキャストしなければならない

ポリモーフィズム

下記はポリモーフィズムを実現している
→振る舞いがSTDIN STOUTによって決まるから

#include <stdio.h>
    void copy(){
        int c;
        while ((c = getchar())  != EOF)
        putchar(c);
    }

  • getcharはSTDINから読み取りを行なっている
    • しかしどのディバイスがSTDIN?
  • putcharはSTDOUTから書き込みを行なっている
    • しかしどのディバイスがSTDOUT?
  • STDINとSTDOUTは、各デバイスの実装を抱えるJavaのインターフェイスのようなもの
  • 上記にはインターフェースはない
  • getcharの呼び出しは、UNIXで標準提供が求められている5つのつの機能で文字を読み取るディバイスにつながっている
    • open,read,close,write,seek

FILEの構造体と関数に関しては下記のようになっている

#include "file.h"

//FILEデータ構造体には、関数へのポインタが5つ含まれている
struct FILE{
    void(*open)(char* name, int mode);
    void(*close)();
    int(*read)();
    void(*write)(char);
    void(*seek)(long index, int mode);
};

//コンソール用のIOドライバでは、これらの関数を定義し、そのアドレスでFILEデータ構造体をロードする
void open(char* name, int mode){
    /*...*/
}
void close(){
    /*...*/
};
intread(){
    int c;
    /*...*/
    return c;
}
void write(charc){
    /*...*/
}
void seek(long index,int mode){
    /*...*/
}
struct FILE console = {
    open, close, read, write, seek
};

STDINがFILE*として定義されていて、それがコンソールのデータ構造体を指しているのであれば、getchar()は以下のようになる

extern struct FILE*STDIN;
int getchar(){
//STDINが指しているFILEデータ構造体のreadポインタが指している関数を呼び出しているだけ
    return  STDIN->read();
}

  • ポリモーフィズムは関数へのポインタの応用

  • OO言語が提供したわけではないが、安全かつ簡単にポリモーフィズムを提供してくれた

    • ポインタを初期化するときは「ポインタを経由して関数を呼び出す」という規約を覚えておかないとバグの追跡と排除が相当難しくなる
  • OOは間接的な制御の移行に規律を課すものであると結論づけることができる

ポリモーフィズムによって何が嬉しいのか?

  • 上記で示した通り、新しいIOディバイスが作成されても既存のコードは変更しなくていい
  • 典型的な呼び出しツリーではmain関数が上位レベルの関数を呼び、それが中間レベルの関数を呼び、それが下位レベルの関数をよぶ。
    • ソースコードの流れは制御の流れに従っている
    • mainが上位レベルの関数を呼ぶにはその関数を含むモジュールの名前に言及する必要がある
    • 制御の流れはシステムの振る舞いによって決まり、ソースコードの依存関係は制御の流れによって決まる

だが、下記のようにポリモーフィズムを利用すると全く異なることが発生する

  • ML1とインターフェイスIのソースコードのソースコードの依存関係(継承関係)が、制御の流れと逆転している
  • OO言語が安全で便利なポリモーフィズムを提供しているというのは、ソースコードの依存関係は(たとえどこにあっても)逆転できることを意味する

まとめ

OOとは「ポリモーフィズムを使用することで、システムにある全てのソースコードの依存関係を絶対的に制御する能力」である

関数化プログラミング

  • Javaプログラミングは可変変数を使用するが、Cloujureプログラムはそのような変数はない
  • 関数型言語の変数は変化しない
  • アーキテクチャの観点からすると、変数の可変性に配慮すべきで、競合状態、デッドロック状態、並行更新の問題の原因が全て可変変数にあるから
  • しかし、ストレージとプロセッサ速度的な観点から上記は現実的なのか?
  • そこで可変性を分離することで妥協する
    • アプリケーションのコンポーネントを「可変コンポーネント」と「不変コンポーネント」に分ける
    • 不変コンポーネントは可変変数を使わずに、純粋に関数的タスクを行う
    • 不変コンポーネントは変数の状態の変更を許可している((純粋に関数的タスクを行わない)1つ以上のコンポーネントと通信する
  • 適切に構造化されたアプリケーションは、変数を変更しないコンポーネントと変更するコンポーネントに分類されており、アーキテクトはなるべく不変コンポーネントにできるだけ多くの実装を押し込み、可変コンポーネントからできるだけ多くのコードを追い払うべき

イベントソーシング

  • 顧客の口座残高を取得するアプリケーションを例にする
  • 残高を知りたい時、これまでの取引全てを計算すると可変変数は必要ない
  • 無限のストレージと無限の処理能力が必要に思われるかもしれないが、アプリケーションの寿命分機能させれば良いのですでに十分な記憶容量と処理能力を持っている
  • イベントソーシングは状態ではなく取引(トランザクション)を保存するという戦略である
    • 毎晩0時の状態を計算しておいて、状態が必要になった時に0時からの取引のみを計算するというショートカットもある
  • 十分な記憶容量と処理能力があれば完全に関数型になる。
  • この仕組みはコード管理システムと全く同じである

まとめ

  • 構造化プログラミングは直接的な制御の移行に規律を課すもの

  • オブジェクト指向プログラミングは、間接的な制御の移行に規律を課すもの

  • 関数型プログラミングは、代入に規律を課すもの

  • これら3つのパラダイムは何をすべきでないかを教えてくれている

  • ソフトウェア(コンピュータープログラム)の本質は「順次」「選択」「間接参照」で構成されている
    それ以上でも以下でもない

Discussion