turned on laptop computer

プログラミングの考える技術

こんにちは。やまもとです。

プログラミングの学習をする際には、プログラムを書く技術だけでなく、プログラムを考える技術の習得もしておいた方がいいと思います。実際、個人的には、プログラマーにお仕事をお願いする時も、プログラムを自分で考え実装していくことを期待しています。プログラムを書けることは当たり前で、「どう書けばいいか」まで噛み砕くような余裕はないことが多いためです。

特に、これまで誰も解決したことがない課題を解く場合や、これまで誰も実現したことのない実装を組み立てる場合は、依頼する側も含めて、誰もプログラムの書き方を知りません。このような、ゼロから生み出す必要がある場合(すなわち、クリエイティブな場合)は、特に考える技術が重要になります。

ここでは、考える技術の底上げのため、自分がプログラミングするときの考え方を、共有しておこうと思います。特に、根幹部分として以下の2つのポイントを書いておきます。

  1. プログラムとして実装できるのか(プログラミング思考)
  2. プログラムをどう設計すればよいか(パラダイム思考)

これらを理解していただくと、プログラムを考える切り口の数が増えます。切り口が増えた分だけ、課題に対するより適切な選択ができるようになるため、考える技術のレベルアップにつながります。しかし、切り口は多くの派生があるため、ここ紹介するのは汎用性が高いごく一部に絞っています。そのため、今後も切り口を増やす学習をし続けなければなりません。

プログラミング思考

ソフトウェア・プログラムは、次の3つの基本的処理で成り立っています。

これらの処理は、どんなプログラミング言語にも共通しています。例えば、手続き型のプログラミング言語であれば、構文(for文、foreach文、while文、goto文など)の違いはあっても、反復処理には変わりありません。関数型のプログラミング言語の場合は、再帰呼び出しが反復処理を実現しています。

プログラミング言語に共通した3つの基本的処理は、プログラミング言語の文法に依存しない普遍性を持っていると言えます。言い換えると、文法が定義されていなくても、「実行すべき仕事を3つの基本的処理に分解して考える」という考え方が存在しています。このような文法に依存しない考え方を、プログラミングの考え方(プログラミング思考)と呼ぶことにします。

3つの基本的処理に分解するため、プログラミング思考の基本的な問いは次のようになります。

その課題は、データさえ差し替えれば、いくつかの処理の組み合わせや同じ処理の繰り返しで解決できるか?または、条件分岐で課題を分けることができるか?

ただし、反復と条件分岐には、必要条件があります。反復で課題解決するには、様々なデータに対して1つの共通処理がなければなりません。条件分岐で課題を解決するには、分岐のための条件がもれなく定義されていなければなりません。逐次処理は、繰り返し1回の反復処理と考えて、反復の中に含めることができます。

そのため、プログラミング思考の基本的な問いは、次の2つの問いに分解されます。

その課題の共通処理は存在するか?

その課題がとりうる全ケースの条件を洗い出せるか?

プログラマーの皆さんは、常にこの2つを意識しておくことをお勧めします。

もし、仕様1〜10のプログラムを仕様通りに作るとしても、この問いを意識していない人は、仕様1のプログラム、仕様2のプログラム、・・・、と10個のプログラムを個別に開発してしまうかもしれません。これでは、開発に時間もかかるし、コード規模も大きくなりメンテナンスが大変になります。この問いを意識していると、仕様1〜10の共通処理はないか、条件分岐で処理できないかと考えるため、仕様1〜10の独自処理だけを残して、共通化することで、開発時間を短縮し、コード規模も最小限に抑えることができるようになります。

ここまでは、課題を「どうプログラムに翻訳していくか」というプログラミング思考の話でした。しかし、プログラマーは、これだけでは力が足りません。課題解決のために「どうプログラムを構成していくか」というプログラミング・パラダイム思考も必要だからです。

パラダイム思考

ソフトウェア開発に携わっていると、「3ヶ月前の自分のコードは他人のコードと思え」といった格言を耳にするかもしれません。

これは、「3ヶ月も経つと、開発した本人でさえコードの解読が困難になる」ことを言い表しています。そうならないために、「コメントを残しましょう」とか「ドキュメントを作りましょう」といった開発ルールがよく設置されます。特に、複数人で開発している場合、ほぼ確実に、他人が開発したコードを解読する必要に迫られることになります。しかし、開発中のプログラムにはドキュメントが整備されていないことも多々あります。

このとき、もしプログラミング・パラダイムを知っていれば、解読が格段に早くなります。プログラミング・パラダイムは、プログラマー間で共有されている共通認識のことで、共通認識で解釈を補完できるのでコードの解読が早くなります。逆に、他人が見ても解読しやすいコードを書くためには、プログラミング・パラダイムに合わせてコーディングする必要があります。

特に、C++言語はマルチパラダイム言語とも言われており、適切なパラダイムを選択する目利きが必要になります。逆に、Java言語はオブジェクト志向パラダイムに特化した言語なので、パラダイムの目利きは必要ないかもしれません。

ここでは、主なプログラミング・パラダイムをご紹介しておきます。詳しくは、Wikipediaのプログラミングパラダイムを見た方がいいでしょう。

命令型プログラミング

上記の逐次・反復・条件分岐といった命令や算術命令などを逐次記述していくプログラミング言語またはプログラミング・スタイルです。プログラミングとして最初に習うパラダイムでしょう。例えば、アセンブリ言語は基本的に命令型プログラミングしかできません。

高級プログラミング言語の場合でも、ちょっとした処理をさせたい場合は、命令型プログラミングを用いることが多いです。例えば、次のようなPythonコードは、命令型プログラミング・パラダイムを使用していると考えられます。

import pandas as pd
data = pd.read_csv("data.csv")
data.describe()

このコードは、Pandasライブラリを読み込み、CSVデータを読み込んで、基本統計量を表示する、という命令を実行順に記述しています。このように、命令型プログラミング・パラダイムは、実行すべき命令を上から順番に書いたスタイルになります。

手続き型プログラミング

命令型プログラムには構造と呼べるものがなく、プログラム設計を考えるコストが低いため、再利用を考えない1回限りのプログラムなどでとても便利です。でも、せっかく作ったプログラムなら、色々な場面で再利用したいと考えてしまいますよね。

そこで、処理を再利用できるようにしたのが手続き型プログラミングです。手続き型プログラミング・パラダイムは、一連の処理をサブルーチン関数といった手続きにまとめていく、プログラミングのスタイルです。

手続き型のプログラミング言語といえば、C言語でしょう。C言語は、全てのプログラムを関数として書かなければなりません。そのほかにも、C言語の文法をベースにしたプログラミング言語は、ほとんど手続き型プログラミング・パラダイムで書くことができます。例えば、Pythonだと次のようになります。

import pandas as pd

def print_stats( csvfile )
    data = read_csv(csvfile)
    data.describe()

print_stats("data.csv")

これは、関数print_statsを定義しています。ポイントは、読み込むファイル名csvfileを変数にし、関数の引数にすることで、ファイル名を変更可能にしていることです。このように、処理の中で変更可能な部分を変数化することで、手続きを汎用的なものにしています。

構造化プログラミング

Wikipediaによれば、構造化プログラミングは次の3つの意味で用いられ、多義的で定義が曖昧です。

  1. 制御構文(順次・選択・反復)だけを使い、goto文を使用しないプログラミングスタイル
  2. 基本処理単位(1入力-内部操作-1出力)の連鎖と内部操作の詳細化によってプログラムを設計する段階的詳細化法(ジャクソンの構造化プログラミング)
  3. ダイクストラの構造化プログラミング

制御構文によるプログラミングは、前述のプログラミング思考法そのものです。また、ダイクストラは構造化プログラミングの提唱者ですが、その考え方は広まりませんでした。ここでは、現在でも使用されるジャクソンの構造化プログラミングを、定義として使用したいと思います。

ジャクソンの構造化プログラミングで用いられる基本処理単位とは、次のような1つの入力データ+1つの内部操作+1つの出力データのセットです。

内部操作は、C言語では関数として表現することができます。

int function( int arg1, int* arg2, double* arg3){ // arg1~3が入力
 
    int output1 = call_function1( arg1 );  // 内部操作
    int output2 = call_function2( arg2 );  // 内部操作
    int output3 = call_function3( arg3 );  // 内部操作

    return (output1 + output2 + output3 ); // 出力
}

この関数は、入力arg1〜arg3を、より詳細な基本処理単位call_function1~call_function3のそれぞれに入力に分けています。出力は、整数値output1~output3の合計値を1つだけ戻す形になっています。これは、抽象的な内部操作functionを、内部操作call_function1~3に段階的に詳細化しています。そのため、段階的詳細化法と呼ばれています。

しかし、上記の関数はfunctionは、入力値が3つあるため、実は基本処理単位になっていません。基本処理単位は、入力値と出力値が1つである必要があるためです。functionを基本処理単位にするには、引数arg1~arg3を1つのデータにまとめる必要があります。

C言語では、構造体でデータをまとめることができます。例えば、次のような形です。

struct args_t  {
    int arg1;
    int* arg2;
    double* arg3;
};

int function( struct args_t* args ){ // 入力データが1つ
 
    int output1 = call_function1( args->arg1 );  // 内部操作
    int output2 = call_function2( args->arg2 );  // 内部操作
    int output3 = call_function3( args->arg3 );  // 内部操作

    return (output1 + output2 + output3 ); // 出力
}

構造体で入力データを1つにまとめたので、関数functionが基本処理単位になりました。

実は、これによって入力データの構造内部操作の連鎖構造が、同じ構造になったことはお分かりいただけるでしょうか?

どちらも、次のようなツリー構造になっています。ただし、入力データは包含関係のツリー構造で、内部操作は呼び出し関係のツリー構造です。

プログラミングの構造化

こうして、膨大なプログラムも、基本処理単位に分割することで、データと処理が構造化されていくことになります。

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

内部構造

さて、上の例だと、構造体argsは関数functionでしか使われないし、データarg1も関数call_functions1でしか使われません。しかも、データと処理は同じツリー構造をしています。そうであれば、次のように、データと処理を1つにまとめた方がシンプルな気がしませんか?

このように、データと処理をまとめたものをオブジェクト(物体)と呼び、オブジェクトを基本単位として構造化していくプログラミング・パラダイムをオブジェクト指向プログラミングと言います。

多くの言語では、オブジェクトは、クラス(設計図)インスタンス(実体)に分けられています。実際に、プログラムの中でデータを格納して使用されるのは、インスタンスの方です。

例として、C++言語では、次のようなプログラムになります。

class Object {  // 設計図
private:
    // メンバー変数
    int m_arg1;
    int* m_arg2;
    double* m_arg3;
public:
    // コンストラクタ
    Object(int arg1, int* arg2, double* arg3)
          : m_arg1(arg1), m_arg2(arg2), m_arg3(arg3)
    {};
    // デストラクタ
    ~Object(){};
    // メンバー関数
   int function();
protected:
    // 内部操作
    int call_function1();
    int call_function2();
    int call_function3();
};

int Object::function(){ // 入力データが1つ
 
    int output1 = call_function1(m_arg1);  // 内部操作
    int output2 = call_function2(m_arg2);  // 内部操作
    int output3 = call_function3(m_arg3);  // 内部操作

    return (output1 + output2 + output3 ); // 出力
};

int main(int argc, char** argv){

     int dat2[5] = {1,2,3,4,5};
     double dat3[5] = {0.0,0.1,0.2,0.3,0.4};

     Object obj = new Object(5,dat2,dat3); // インスタンス化

     int result = obj.function();

     return result;
};

これは、設計図と物の関係を想像すると分かりやすいかもしれません。例えば、炊飯器の設計図(クラス)ではご飯は炊けませんが、その設計図から炊飯器自体(インスタンス)を作れば、その炊飯器を使ってご飯を炊く(プログラムを実行する)ことができるようなものです。

包含構造

データと処理のセットがオブジェクトでしたが、オブジェクト指向プログラミングではオブジェクト同士の関係性を考える必要があります。

オブジェクト同士の関係性の中で、最も安全で副作用が少ないのは、包含関係だと言われています。

包含関係は、別のオブジェクトを内包する形でオブジェクトを設計する方法です。例えば、自動車は何万個もの部品で作られていますが、それらの部品一つ一つもオブジェクトです。つまり、部品ようなオブジェクトを組み立てて、自動車のような別のオブジェクトを作り出す場合、部品は自動車の包含関係にあると言います。これを図示すると、次のようなツリー構造になります。

もちろん、エンジンも、ハンドルも、ブレーキも、さらに細かい部品から構成されているので、このツリー型の包含構造はもっと巨大なものになります。

これを、C++言語で記述してみると次のようになります。

class Engine {
public:
    int ignition();
};

class Handle{
public:
    int rotate();
};

class Break{
public:
    int step_on();
};

class Car{
private:
    Engine m_engine;  // 包含=メンバー変数として定義する
    Handle m_handle; // 包含=メンバー変数として定義する
    Break m_break; // 包含=メンバー変数として定義する
};

継承構造

オブジェクト指向プログラミングを学習すると、新しく「継承」という概念を学ぶので、つい使いたくなります。しかし、継承は包含ほど安全ではないことが明らかになっています。そのため、継承か包含か判断が必要になったら、まずは包含関係にしておきましょう。継承は、継承でなければならない場合だけに使います。

継承でなければならない場合とは、あるオブジェクトが別のオブジェクトのフリをしなければならない場合です。これを、仮に「擬態」と呼ぶことにします。

例えば、次のような名前を出力する関数があったとします。

void print_objname( const Object & obj ){
    std::out << Object.name() << std::endl;
};

この関数を使って、次のような自動車クラスCarの名前を表示させるにはどうしたら良いでしょう?

class Car
{
  public:
     const char* name()const { return "Car"; }
};

int main(int argc, char** argv)
{
     Car car = new Car();
     print_objname(car); // syntax errorになる
};

CarインスタンスはObjectクラスではないので、当然、print_objname(car)は文法エラーになります。文法エラーにならないようにするには、CarインスタンスはObjectクラスに擬態できなければなりません。このような擬態が必要な場合は、次のように継承を使うしかありません。

class Object
{
   public:
     const char* name()const { return "Object"; }
}

class Car : public Object // 公開継承する
{
  public:
     const char* name()const { return "Car"; }
};

int main(int argc, char** argv)
{
     Car car = new Car();
     print_objname(car); // syntax errorにならないが、"Object"と表示される。
};

このように継承すると、CarはObjectを擬態することができるようになります。しかし、このままではObjectクラスのname関数が呼び出されてしまい、Carクラスの名前を表示できません。擬態したCarオブジェクトのname関数が呼び出されるようにするには、Objectクラスのname関数を仮想化してあげる必要があります。

class Object
{
   public:
     virtual const char* name()const { return "Object"; } // 継承先で上書きできるように仮想化する
}

class Car : public Object
{
  public:
     const char* name()const { return "Car"; }
};

int main(int argc, char** argv)
{
     Car car = new Car();
     print_objname(car); // syntax errorになず、"Car"と表示される。
};

これで、CarはObjectをきちんと擬態できるようになりました。Carクラスの関数name()はObjectクラスの仮想関数name()を上書きしているので、これを「オーバーライド(上書き)」と言います

このように、擬態が必要な場合は、継承を使用します。継承構造としては、単一継承構造を覚えておけば十分でしょう。C++言語では多重継承も可能ですが、上手な使い方の難易度が高すぎて、ほとんど使われません。Java言語のような単一継承モデルは、継承構造は次のような物体(Object)クラスを最上位にもつツリー構造になっています。

ジェネリックプログラミング

ここまで読んでいただければ分かるかもしれませんが、同じ処理をするにしても命令型プログラミングに比べてオブジェクト指向プログラミングはコード量がかなり多くなります。そのため、既存のクラスAを少し変更したクラスA’を作ろうとすると、本来変更したい箇所以外にも多くの変更が必要になり、手間がかかります。

特に、int型をdouble型に変更するといったデータ型だけを変更する場合、データ型に依存する部分を全て変更しなければなりません。この手間を減らすために考え出されたのが、データ型を変数に抽象化するテンプレート技術です。このテンプレートを使って、複数のデータ型に対応するプログラミグ・スタイルをジェネリック・プログラミングと言います。

例えば、次のような自動車クラスがあったとき、エンジンはすでに定義されたEngineクラスを用いています。

class Car
{
private:
    Engine m_engine;  // 包含
    Handle m_handle; // 包含
    Break m_break; // 包含
};

これが、ガソリン・エンジンだった場合、ハイブリッド・エンジンや電気モーター、水素燃料モーターの自動車を定義するには、新たにHybridCarクラス、EletoricCarクラス、HidrogenCarクラスなどを定義しなければなりません。

class HybridCar
{
private:
    HybridEngine m_engine;  // 包含
    Handle m_handle; // 包含
    Break m_break; // 包含
};

class EletoricCar
{
private:
    ElectoricEngine m_engine;  // 包含
    Handle m_handle; // 包含
    Break m_break; // 包含
};

class HydrogenCar
{
private:
    HydrogenEngine m_engine;  // 包含
    Handle m_handle; // 包含
    Break m_break; // 包含
};

しかし、自動車の走る・曲がる・止まるといった処理は、エンジンタイプに関わらず共通です。そのため、これらの新たなクラスは、Carクラスと9割程度は同じコードになることが予想されます。もし、Carクラスに不具合が見つかった場合、それをコピーした3クラスも全て修正が必要になります。つまり、4倍の手間がかかります。

このような状況は、次のようにエンジンタイプをテンプレートパラメータにしておくと解決することができます。

template<EngineType>
class Car
{
private:
    EngineType m_engine;  // 包含
    Handle m_handle; // 包含
    Break m_break; // 包含
};

typedef Car<Engine>            GasCar;
typedef Car<HybridEngine>   HybridCar;
typedef Car<ElectoricEngine>  ElectoricCar;
typedef Car<HydrogenEngine> HydrogenCar;

こうしておくと、Carクラステンプレートの不具合はCarクラステンプレートを修正するだけですみます。

さらに、別の新しいエンジンが出てきても、エンジンクラスを作れば、typedefを追加するだけで自動車クラスを定義することができます。新たなエンジンクラスと新たな自動車クラスの2つを開発するのに比べれば、開発コストは半減することになります。

まとめ

ということで、プログラミングの書き方ではなく、考え方についてまとめてみました。他にも、インターフェースとかイベントドリブンとか色々ありますが、プログラミング初心者の方に身につけてももらいたい根幹の部分を書いたつもりです。

大きく分けるとプログラマーは2つのことを考える必要があります。

  1. 逐次・反復・条件分岐で、実装できるか?(プログラミング思考)
  2. どのパラダイムを使うべきか?(パラダイム思考)

なお、プログラミング・パラダイムの使い所は、次のようにまとめられます。

パラダイム特徴プログラミング欲求使い所
命令型プログラミング命令を順番に記述したもの複数の命令を一度に実行したいちょっとしたスクリプト
手続き型プログラミング処理を関数にまとめたもの再作成せずに、処理(一連の命令)を再利用したい。自分用のプログラム
構造化プログラミング入力データを1つにまとめたもの複数人でプログラム開発したい小規模システム
オブジェクト指向プログラミングデータと処理を1つにまとめたもの大規模システムを開発したい大規模システム
ジェネリック・プログラミングデータ型をパラメータ化したものメンテナンス性と汎用性を向上したい汎用ライブラリ

フルスクラッチからプログラムを作成する場合は、命令型で作ってみて、手続型に移行し、構造化・オブジェクト指向へと徐々に抽象度を上げていくのが一般的だと思います。

コメントを残す