はじめに
オブジェクト指向の説明がよく分からないのは「犬」や「猫」で説明するからだ、という文章を見たことがあるが、実際に「犬」や「猫」で説明されている解説は少ししか見つからない。
https://tec.citrussin.com/entry/2015/08/13/155115
http://www.ie.u-ryukyu.ac.jp/~e085739/java.good.11.html
こういうことなのかな?
それとも書籍のことなのか?Googleに価値がないと思われて検索では出てこないのか?などと思いつつ、本当に犬や猫では説明しづらいのか試してみる。
Animal, Cat, Dogクラス
参考URLに習って、JavaScriptでクラスを書いてみる。
Animalクラスを継承してCat、Dogクラスを作る。
/**
* @abstract
*/
class Animal{
/**
* @param {string} name
* @param {number} age
*/
constructor(name, age){
/**
* @private {string}
* @const
*/
this.name_ = name;
/** @protected {number} */
this.age_ = age;
}
/**
* @public
* @return {string}
*/
name(){
return this.name_;
}
/**
* @abstract
* @public
* @return {string}
*/
bark(){}
}
class Cat extends Animal {
bark(){
return this.name() + ' > nya';
}
}
class Dog extends Animal {
bark(){
return this.name() + ' > waoon';
}
}
var animals = [
new Cat('tama1', 2),
new Cat('tama2', 3),
new Dog('pochi1', 5),
new Dog('pochi2', 7)
];
animals.map((a) => console.log(a.bark()));
DogとCatで共通の処理をAnimalクラスに作る。
動きが異なる部分の実装は、それぞれの子クラスに実装する。
鳴き声bark()
を Dog、Catそれぞれで実装して抽象化できた。
tama1 > nya
tama2 > nya
pochi1 > waoon
pochi2 > waoon
現実世界のモデリング
Animalクラスを継承してCat、Dogクラスを作ることは出来た。ただ、確かに何が嬉しいのかは分からない。
現実世界をそのままプログラムにしようとしても上手くいかないというのはよく言われているが、本当だろうか?現実世界をオブジェクトに対応させるという作業がまだまだ全然足りないのではないだろうか?
「犬」や「猫」をプログラムとして表現するための「世界」として「console
」を使うのが良くない気がする。
せっかくJavaScriptで書き始めたので、世界を「console
」ではなく「document
」としてみるとどうなるだろう?
まずは「世界」を作り、「世界」に「犬」「猫」を配置する必要がありそうだ。この世界では分子や細胞の代わりにHTML
で存在が創造されることになる。
世界は一つなのでnew
しなくても良いだろう。
const TheWorld = {
create: () => {
var animals = [
new Cat('tama1', 2),
new Dog('pochi1', 5),
];
var world = document.getElementById('world');
animals.map((a) => world.appendChild(a.create()));
animals.map((a) => a.barkWorld());
}
};
TheWorld.create();
世界が想像されたとき、同時に世界は犬と猫を創造する。
それから鳴き声を発する。
createのメッセージを受け取ったAnimalは、自分でHTMLを構築する。
鳴き声も単なる文字列ではなくて、HTMLの世界で意味のあるものでなくてはならない。
/**
* @abstract
*/
class Animal{
// ...
/**
* @return {Element}
*/
create(){
var el = document.createElement('div');
var imgEl = document.createElement('img');
imgEl.src = this.looks();
imgEl.title = this.name();
el.appendChild(imgEl);
this.el_ = el;
this.imgEl_ = imgEl;
return this.el_;
}
/** @public */
barkWorld(){
this.el_.appendChild(document.createTextNode(this.bark()));
}
/**
* @abstract
* @protected
* @return {string}
*/
looks(){}
}
class Cat extends Animal {
// ...
/** @override */
looks(){
return cat1_gif();
}
}
オブジェクトは自分のことは自分でやるので、鳴き声を世界に表現するのはAnimal自身である。Worldではない。
See the Pen jsobj1 by Satoshi Nishimura (@n314) on CodePen.
Worldが Animal#barkWorld を呼び出すのも変なのだが、ひとまずこれで。
アイコンは https://hpgpixer.jp/ こちらのものを使った。
素っ気ない文字列が少し可愛くなったので説得力が増したはずである。
よくあるPointクラスでオブジェクトを世界に配置する
構造体やクラスの説明で度々出てくるのがPoint
クラスである。
class Point {
/**
* @param {number} x
* @param {number} y
*/
constructor(x, y){
/** @public {number} */
this.x = x;
/** @public {number} */
this.y = y;
}
}
現実世界の座標だと緯度と経度で表すことになるだろうか。zがないのでジャンプはできない。
先程はWorld
がappendChild
していたが、オブジェクト指向ならメッセージを送るだけ(Animalクラスのメソッドを実行するだけ)にするべきだった。
また、Animalだけでなく、どんなものでもHTMLの世界で表現するにはappendChildする必要があるので、全ての祖先のオブジェクトになると言える。
そうするとHTML世界の共通のオブジェクトは
/**
* @abstract
*/
class HtmlObject {
constructor() {
/** @protected {Element} */
this.el_ = null;
/** @protected {Point} */
this.place_ = null;
/** @protected {HTMLDocument} */
this.world_ = null;
}
/**
* @param {!HTMLDocument} world
* @param {!HTMLElement} area
* @param {?Point=} opt_place
*/
enterWorld(world, area, opt_place) {
this.world_ = world;
var place = opt_place || new Point(0, 0);
var el = this.create(world);
el.style.left = place.x + 'vw';
el.style.top = place.y + 'vh';
el.classList.add('htmlobject');
area.appendChild(el);
}
/**
* @public
*/
exitWorld() {
this.el_.parentNode.removeChild(this.el_);
}
/**
* @param {!HTMLDocument} world
* @return {Element}
*/
create(world){
this.el_ = world.createElement('div');
return this.el_;
}
}
こんな感じになるだろうか。
Animalクラスは
/**
* @abstract
*/
class Animal extends HtmlObject {
// ...
enterWorld(world, area, opt_place) {
super.enterWorld(world, area,
opt_place || new Point(Math.random()*80,Math.random()*80));
}
// ...
}
こんな感じでランダムに出没するようにできた。
documentを直接使わないようにすることで、currentDocumentでない別のHTML世界にも出現できるようになった。
それから、鳴き声もオブジェクトである。
今までも文字列オブジェクトと言えたが、HTML世界のオブジェクトにした方が柔軟性がある。
class AnimalCall extends HtmlObject {
/** @param {string} msg */
constructor(msg) {
super();
this.msg_ = msg;
}
/** @override */
create(world) {
var el = super.create(world);
el.classList.add('animalcall');
el.innerHTML = this.msg_;
return el;
}
/** @override */
enterWorld(world, area, opt_place) {
super.enterWorld(world, area, opt_place);
var time = 3000;
setTimeout(() => {
this.el_.style.opacity = 0;
}, time);
setTimeout(() => {
this.exitWorld();
}, time + 1000);
}
}
鳴き声も自分のことは自分でする。
これでHTMLの世界に生まれた鳴き声は時間が経つと消えていくようになった。
See the Pen jsobj2 by Satoshi Nishimura (@n314) on CodePen.
右下Rerunボタンでランダム配置+鳴き声、クリックで鳴き声。
何かに反応して動くメソッドを作る
Animalに何か動きをさせたいのでメソッドを追加する。
ここで、「クリックした場合」のような動きは犬と猫に似合わないので、もう一つ不思議パワーのクラスを導入する。
class WonderPower extends HtmlObject {
enterWorld(world, area, place) {
var el = super.enterWorld(world, area, place);
el.style.left = (place.x - 8) + 'px';
el.style.top = (place.y - 8) + 'px';
var time = 500;
setTimeout(() => {
this.el_.style.opacity = 0;
}, time);
setTimeout(() => {
this.exitWorld();
}, time + 1000);
return el;
}
}
世界をクリックした場合、世界に不思議パワーが生成される。Animal達はそれを見つけて行動を起こすようにする。
class Animal extends HtmlObject {
// ...
/** @override */
enterOtherObjectInWorld(obj){
if (!(obj instanceof WonderPower))
return;
this.barkWorld();
this.act(obj);
}
/**
* @abstract
* @public
* @param {HtmlObject} obj
*/
act(obj){}
// ...
}
HtmlObjectに共通処理を追加、そしてCatとDogのact
をそれぞれ実装する。
class Cat extends Animal {
// ...
/** @override */
act(obj){
clearTimeout(this.moving_);
this.imgEl_.src = cat2_gif();
this.moving_ = setTimeout(() => {
this.imgEl_.src = this.looks();
}, 5000);
}
}
また、移動するときにベクトル計算をしたいのでPointクラスを演算できるようにする。
class Point {
/**
* @param {number} x
* @param {number} y
*/
constructor(x, y){
/** @public {number} */
this.x = x;
/** @public {number} */
this.y = y;
}
/**
* @public
* @param {!Point} p
* @return {!Point}
*/
plus(p){
return new Point(this.x + p.x, this.y + p.y);
}
/**
* @public
* @param {!Point} p
* @return {!Point}
*/
minus(p){
return new Point(this.x - p.x, this.y - p.y);
}
/**
* @public
* @param {number|!Point} p
* @return {!Point}
*/
multi(p){
if (!(p instanceof Point))
p = new Point(p, p);
return new Point(this.x * p.x, this.y * p.y);
}
/**
* @public
* @return {!Point}
*/
unit(){
var vector = Math.sqrt(this.x ** 2 + this.y ** 2);
return new Point(this.x / vector, this.y / vector);
}
}
ageを使ってなかったのでageによって動きに差が出るようにして、そして最終的にはこうなった。
See the Pen jsobj3 by Satoshi Nishimura (@n314) on CodePen.
犬猫を増やす場合はWorldの中の生成部分を増やせばよく、Dogの動作を変えたい場合はDocのactを変更すれば良い。
鳴き声の内容を変えたいときはbarkを変える。鳴き声の表現方法を変えたいときはAnimalCallクラスを変える。
それぞれのオブジェクトが独立して動いている感じが分かるだろうか。
まとめ
ここまで一気に作ってきたわけだが、Animalを継承してDog、Catを作ることが自然な流れでプログラムできたように思う。
業務システムだとこうはいかない。
より部品っぽく説明するなら「口オブジェクト」や「目オブジェクト」を作るのが現実世界をそのまま実装することになるのかもしれない。HTML世界のオブジェクトはHTMLと密接に結びついているので、もし部品で分けるなら画像も分けるようにしてまばたきしたり口パクしたりできるともっと良さそうだ。
オブジェクト指向をざっくり言うと、自分のことは自分でするということだと思っている。
WorldがDogのHTMLタグを操作したり、Dogが鳴き声のHTMLを操作したりせず、それぞれのオブジェクトが自分自身を操作する。オブジェクト同士は簡単な司令を渡すだけにする。
オブジェクト指向で重要なのは、継承、カプセル化、ポリモーフィズムだと言われているが、上記に気をつけていれば大抵は自然に実装できるように思う。
今回の例だと継承ツリーは
- HtmlObject
- Animal
- Dog
- Cat
- WonderPower
- AnimalCall
- Animal
となっている。
HtmlObjectを継承すればどんなオブジェクトでもenterWorldでHTMLとして表現されるようになる。DogもAnimalCallもenterWorldでHTMLとして表示されるので自動的にポリモーフィズムが実現できていた。
逆に、実装の継承をしすぎないように注意する必要はある。
今回の場合では、HtmlObjectを継承してAnimalを作ったので、Animal達はconsoleの世界には戻れなくなってしまった。
consoleにアスキーアートで絵を書いて犬が動くようにするためには、継承ではなくて委譲にする必要がある。
その場合はHTMLDocument
やElement
という具体的な型を受け取るのではなくWorld
やWorldObject
といったように抽象度を一段上げて、Presenter
を継承したHtmlPresenter
やConsolePresenter
などのクラスを作り、UIに関わるプログラムを分離すれば切り替えられる。
ただコードの量は膨大になり複雑にもなるので、そこまでして柔軟にするかどうかを判断することが難しい。
どこまで柔軟にするか、差し替え可能にするか、ということを未来を予測して決めなければいけないことがオブジェクト指向は難しさなんじゃないだろうか。
今回は「犬」「猫」のような明らかにオブジェクトとして扱えるものを書いたが、「DBからデータを取得する」「画面から入力を受け取って保存する」のような処理の流れに関しては、自分はオブジェクト指向を使わないことが多い。
そういったものより、むしろ「犬」「猫」の方が説明する対象として合っているのではないかと思う。