オブジェクト指向の良さがちょっとわかった
オブジェクト指向は難しい
「オブジェクト指向」と言われれば自分の解釈は、「プログラムをひとまとめにして、再利用できるようにした便利なプログラミング手法」的な感じで答えていました。
それもあっているような気がします。
wikipediaによると、オブジェクト指向という造語を作った、アラン・ケイの定義は以下である。
object-oriented means that the object knows what it can do.
(オブジェクト指向とは、オブジェクトが出来る「なにか」を知っていることを意味している)
何を言っているか分からない。
これらを色んな人が再定義し、色んなことを言っているので、これ!といったものはない。というのが今の考えです。。
しっくり来たオブジェクト指向の話
ただ、wikipediaのこの一文が自分はしっくりきました。
オブジェクトとは、プログラミング視点ではデータ構造とその専属手続きを一つにまとめたものを指しており、分析/設計視点では情報資源とその処理手順を一つにまとめたものを指している。データとプロセスを個別に扱わずに、双方を一体化したオブジェクトを基礎要素にし、メッセージと形容されるオブジェクト間の相互作用を重視して、ソフトウェア全体を構築しようとする考え方がオブジェクト指向である。
要するにデータとロジックを一つのまとまりにしたもの
こう考えれば、そう難しくもない。
よくある犬が吠える的なサンプルです。
c言語はオブジェクト指向ではないので、structと関数が分離しています
#include <stdio.h>
typedef struct {
char name[100];
} Dog;
void bark(Dog dog) {
printf("%sはワンワンと吠えている\n", dog.name);
}
int main() {
Dog myDog = {"シロ"};
bark(myDog);
return 0;
}
それに対してC++はオブジェクト指向で書けるので、名前の関数が一つのかたまりとして見ることができます。
#include <iostream>
#include <string>
class Dog {
public:
std::string name;
Dog(const std::string &name) : name(name) {}
void bark() const {
std::cout << name << "はワンワンと吠えている" << std::endl;
}
};
int main() {
Dog myDog("シロ");
myDog.bark();
return 0;
}
オブジェクト指向は何がいいのか
データとロジックを一つのまとまりにすると何がいいのか
逆に考えてみると、データとロジックを一つのまとまりにしないと何が良くないのかを検証していきます。
ちなみに、データとロジックを一つのまとまりにしないで、プログラムを記述する方法を手続き型プログラミングと呼びます。
今話題のBardでサンプルを出力してみました。
Javaで手続き型プログラミングとオブジェクト指向プログラミングの違いをサンプルで示します。
シンプルに人の名前と年齢をデータとし、出力するサンプルです。Javaです。
手続き型プログラミングのサンプル
public class Main {
public static void main(String[] args) {
// データ
String name = "山田太郎";
int age = 30;
// ロジック
System.out.println("私の名前は" + name + "です。年齢は" + age + "歳です。");
}
}
オブジェクト指向プログラミングのサンプル
class Person {
// データ
private String name;
private int age;
// ロジック
public void introduce() {
System.out.println("私の名前は" + name + "です。年齢は" + age + "歳です。");
}
}
public class Main {
public static void main(String[] args) {
// オブジェクトを生成
Person person = new Person("山田太郎", 30);
// ロジックを実行
person.introduce();
}
}
手続き型の方が分かりやすくないでしょうか?
手続き型は行数が少なく簡潔です。
それに対してオブジェクト指向は、記述量が多く分かりにくくする原因になります。
じゃあ実際どのくらいの複雑度で、オブジェクト指向の方がわかりやすくなるのでしょうか?
自分はオブジェクト指向が機能を発揮するのは共通化をしていく段階だと思っています。
学生の学年を判定し卒業生とそれ以外を判別する、大学のシステムを考えてみます。
(留年はなしとする)
手続き型ではデータとロジックが別になるので、以下のようになります。
- 学生のデータを定義
- ロジックにデータを引数として入れる
public class UniversitySystem {
public static String determineGrade(int enrollmentYear, int currentYear) {
int grade = currentYear - enrollmentYear + 1;
if (grade > 4) {
return "卒業生";
} else {
return grade + "年生";
}
}
public static void main(String[] args) {
int currentYear = 2023;
int enrollmentYear = 2020;
System.out.println(determineGrade(enrollmentYear, currentYear)); // 出力: 4年生
}
}
オブジェクト指向においてはデータとロジックを一つにまとめます
- データを入れてオブジェクトを生成
- オブジェクトのロジックを呼び出す
public class Student {
private int enrollmentYear;
private int currentYear;
public Student(int enrollmentYear, int currentYear) {
this.enrollmentYear = enrollmentYear;
this.currentYear = currentYear;
}
public String determineGrade() {
int grade = this.currentYear - this.enrollmentYear + 1;
if (grade > 4) {
return "卒業生";
} else {
return grade + "年生";
}
}
public static void main(String[] args) {
Student student = new Student(2020, 2023);
System.out.println(student.determineGrade()); // 出力: 4年生
}
}
このくらいのサンプルであれば、どちらでも可読性に違和感はなさそうですが
全ての機能に対して卒業生は利用できないことを判定する。
この機能を追加するというケースを考えてみましょう。
このシステムの機能が20機能あったら、全ての箇所でdetermineGrade()を記載する必要があります。
その場合、単にめんどくさいというケースだけではなく、仕様変更に弱いという欠点があります。
例えば1年生の全ての機能も利用できないみたいな、仕様変更をされたとき(極端)
20箇所全てに対して、追加のコードを書くということになりますが、コード変更は人間がやるので、どこかしらでおそらくミスが出ます。
今回の仕様変更は良いとしても、いずれは発生しそうです。
それを防ぐために共通化をすることが必要です。
共通化をすれば20箇所の変更を1回の変更で終わらせることができるので、ミスを減らす事ができます。
共通化の手法
手続き型の場合
関数をUtil, Commonクラスなどに格納しどこからでも呼び出せるようにします。
UniversityUtilsクラスをnewしなくてもよいように、staticメソッドにすることで簡潔にできます。
public class UniversityUtils {
public static String determineGrade(int enrollmentYear, int currentYear) {
int grade = currentYear - enrollmentYear + 1;
if (grade > 4) {
return "卒業生";
} else {
return grade + "年生";
}
}
}
オブジェクト指向の場合
オブジェクトを全ての場所で呼び出すので新たに共通化は必要ないです。
手続き型の場合は、このように共通ライブラリを作ってあげて呼び出すことによって、共通化を実現します。
これにより、仕様変更に耐えうる基盤をどちらのパターンでも作ることができました。
手続き型のデメリット
この「関数をUtil, Commonクラスなどに格納しどこからでも呼び出せるようにします。」
ここに問題が発生する場合があります。
- クラス名が意味を持ちにくい
Utilクラス, Commonクラスという汎用メソッドを置くクラスは、汎用ということを表すために、クラス名という重要な名前空間を使っています。つまりdetermineGrade() = 階級判定
という関数名のみで内容を判定しないといけないです。
これでは何の階級を判定をしているかわかりません。学年ではなく、成績というケースもあります。
オブジェクト指向であればStudent.determineGrade(), Exam.determineGrade()のようにクラス + メソッド名で意味を表現できます。
とはいったものの
packageなどでutilの意味を含めて、クラス名はオブジェクトと同じくStudentとすることで、これを回避できます。
- データを持てない
Utilクラスはインスタンス変数を持てません。newをする想定ではないので。
つまり変数は関数で受け取るか、static変数として固定値を持つことになります。
そのためミュータブルな値は関数に持つという発想になりますが、引数が10, 20に増えた場合、それを関数で受けとるしかないです。
オブジェクト指向であればデータを持つことができるので臨機応変に対応できます。
とはいったものの
引数を構造体でまとめて、10個の引数をまとめて一つの構造体にしてしまえば、解決できます。
- ロジックはロジックで共通化してしまう
ロジックとデータが分離していた場合、人間の心理としてロジックはロジック汎用性を上げるということに注視します。
そのためどのようなデータが来たとしても、対応できるようにするというスーパー関数になりがちです。
ロジックとデータが一緒になることでロジックの範囲を制限し、持っているデータ以上の影響を持たないように実装できます。
とはいったものの
ちゃんと責務を分割できれば、問題なく使えるということでもあります。
結局トレードオフ
上に記載した項目は、手続き型だとやりにくいというだけで、実現は可能です。
なので、オブジェクト指向ではなくてもいいという元の子もない結論になってしまいました。
ここで大事になってくるのが、ソフトウェア開発のチームです。
ソフトウェアは変わらなくても、中の人は変わっていきます。
その時に入ってくるメンバーのレベル感や、バックグラウンドはまばらです。
新卒の人、ジュニアレベルの人、組み込み出身の人、CS卒業した人、大ベテラン。
色んなバックボーンの人間が出たり入ったりして、一つのソフトウェアを組み上げていきます。
コーディングに自由度が高いとどうなるかというと、様々な人が様々な価値観で、コードを組み上げ、最終的にコードが散乱して、何をしているか分からない状態になってしまいます。
個人的にはここでオブジェクト指向が役に立つと思っています。
手続き型における共通化は強制力が低いです。
クラス名や関数名や配置場所などの自由度が高く、どのようにもできます。そのため散らばります。
手続き型は共通化を後から行うので(YAGNI)、個々人の裁量に共通化が委ねられやすいです。
一方でオブジェクト指向は既に共通化という目的は達成しているので、最初の人の思想が受け継がれやすいです。
と、オブジェクト指向寄りの意見を少し述べましたが、大事なのは変更しやすいソフトウェアかどうかということです。
チームの認知やルールなどに従って、様々なバックグラウンドをもつ人でも変更が気軽にできてバグがないソフトウェアをかければ、どんなプログラミング手法や設計手法を用いても問題ないです。
チームやソフトウェアのサイズ、レベル感、速度、様々な要素を照らし合わせながらベストなアーキテクチャを選択するのが、ソフトウェアの面白いとこでもあり、難しい所だと思います
Discussion