やった!オブジェクト指向プログラミングだよ!!!
/src/lib/map.ts
を作成しましょう。
class GameMap {}
GameMap
というクラスを作りました。
(間違っても Map
という名前のクラスを作るんじゃあないぞ!)
クラスというのは、オブジェクト指向プログラミングにおいて非常に重要な概念です。
クラスは、オブジェクトの設計図のようなもので、オブジェクトを作るための型を定義します。(by Copilot)
クラスを使うことで、カプセル化や継承なんかの概念を使うことができます。
まぁまずは Class についてもっとわかりやすく説明するところから始めましょう。
クラスとは?
課題
分かりやすく説明するために、今からあなたにはこの世界の神様になってもらいます。
さぁ!早く TypeScript で生き物を創造してください!
えっと...猫と犬と人間のクラスを作るか...
class Cat {
public name: string = "ねこ";
public legs: number = 4;
private foods: string[] = ["さかな", "ねずみ"];
private reward: string = "またたび";
public meow() {
console.log("にゃー");
}
public sleep() {
console.log("ぐー");
this.energy += 1;
}
public walk() {
position += 1;
}
public feed(food: string) {
if (this.foods.includes(food)) {
console.log("おいしい!");
} else {
console.log("おいしくない...");
}
}
}
class Dog {
public name: string = "いぬ";
public legs: number = 4;
private foods: string[] = ["にく", "どっぐふーど"];
private reward: string = "ほね";
public bow() {
console.log("わんわん");
}
public sleep() {
console.log("ぐー");
this.energy += 1;
}
public walk() {
position += 1;
}
public feed(food: string) {
if (this.foods.includes(food)) {
console.log("おいしい!");
} else {
console.log("おいしくない...");
}
}
}
class Human {
public name: string = "ひと";
public legs: number = 2;
private foods: string[] = ["かんぜんえいようしょく", "やさい"];
private reward: string = "ゆうきゅう";
public quote() {
console.log("我々は神が想像した生き物の中で最も偉大なものである。");
}
public sleep() {
console.log("我々は決して眠る事は許されない。");
}
public walk() {
position += 1;
}
public feed(food: string) {
if (this.foods.includes(food)) {
console.log("大変喜ばしい事である。我は主に最大限の感謝を示す。");
} else {
console.log("主が提供してくれた物であるため、どのような物でも我は喜んで頂こう。");
}
}
}
※private
やpublic
については次回の記事でお話します。
こんな感じ?
あーあ。
確かに間違ってはいません。
いや...人間の表現がなんかあれなんだけど...
これら全てのクラスは大まかに同じような構造を持っています。
それだったら、Animal
といったクラスを作って、そこから継承すれば、それぞれのクラスの共通部分をまとめたり、それぞれのクラスにおける互換性を持たせたりもできます。
abstract class Animal {
public abstract name: string;
public abstract legs: number;
protected abstract foods: string[];
protected abstract reward: string;
protected abstract message: string;
public quote() {
console.log(this.message);
}
public abstract sleep(): void;
public walk() {
if (this.legs > 0) {
position += 1;
} else {
throw new Error("足がないので歩けません。");
}
}
public feed(food: string) {
if (this.foods.includes(food)) {
console.log("おいしい!");
} else {
console.log("おいしくない...");
}
}
}
これで、全ての生き物が継承するべきAnimal
クラスができました。
さらにここに猫、犬、人間を追加してみましょう。
...
class Cat extends Animal {
public name: string = "ねこ";
public legs: number = 4;
protected foods: string[] = ["さかな", "ねずみ"];
protected reward: string = "またたび";
protected message: string = "にゃー";
public sleep() {
console.log("ぐー");
this.energy += 1;
}
}
class Dog extends Animal {
public name: string = "いぬ";
public legs: number = 4;
protected foods: string[] = ["にく", "どっぐふーど"];
protected reward: string = "ほね";
protected message: string = "わんわん";
public sleep() {
console.log("ぐー");
this.energy += 1;
}
}
class Human extends Animal {
public name: string = "ひと";
public legs: number = 2;
protected foods: string[] = ["かんぜんえいようしょく", "やさい"];
protected reward: string = "ゆうきゅう";
protected message: string = "我々は神が想像した生き物の中で最も偉大なものである。";
public sleep() {
console.log("我々は決して眠る事は許されない。");
}
}
このようにextends
を使って、Animal
クラスを継承することで、それぞれのクラスに共通する部分をまとめることができます。
その際、abstract
を使って、Animal
クラスでは定義しなかったメソッドやプロパティについては、それぞれのクラスで実装しなければなりません。
さらに言うと、Animal
クラスはnew Animal()
としてインスタンスを生成することができません。
これは、Animal
クラスが抽象クラスであり、「こんな感じの設計図を作ればいいと思うよ」という先輩からのアドバイスみたいなものだからです。
ちなみにこのクラスを使うためにはnew
を使ってインスタンス化する必要があります。
const cat = new Cat();
const dog = new Dog();
こうすることで、それぞれのクラスのインスタンスを生成することができます。
インスタンス化とは、設計図から実際のオブジェクトを作ることです。
以上これがクラスの概要です。
じゃあマップを作ってよ
言われなくても作ります。
まずは、さっきのもぬけの殻のGameMap
クラスにをabstract
にしたうえで、必要そうなものを考えていきましょう。
abstract class GameMap {}
はい、今回の記事ではこれが全てです。
マップを呼び出す処理を書きたい
後はタイル情報の埋め込みと、キャラクター移動処理、コリジョンの設定などなどなのですが、そこらへんは別の記事になってしまいます。
でも少しづつ現実味を帯びていますよね。
余った時間で、ゲームを管理するためのクラスGame
を作っていきましょう。
これは、1 ゲームにつき 1 つだけ存在するべきクラスとなります。
class Game {}
このGame
には、現在のマップと、マップ切り替えメソッドを追加したいですね。
という訳で、先ほど作ったGameMap
が型となるmap
プロパティを追加しましょう。
class Game {
public map: GameMap | null = null;
public changeMap(map: GameMap) {
this.map = map;
}
}
しかし、おおよそこれではエラーが出るかと思います。
なぜなら、GameMap
というものがどこにあるのか、game.ts
がそれを知らないからです。
なので、インポートしてあげましょう。
import { GameMap } from "./map";
class Game {
public map: GameMap | null = null;
public changeMap(map: GameMap) {
this.map = map;
}
}
これで、Game
クラスがGameMap
クラスを認識できるようになりま...あれ?
と、TypeScript でこのようにモジュール化したりするときに、忘れてはいけないことがあります。
それはexport
です。
指定したものを、別のファイルなどで使用できるようにするには、export
を使ってエクスポートする必要があります。
map.ts
のGameMap
クラスにexport
を追加しましょう。
export abstract class GameMap {}
これで、Game
クラスがGameMap
クラスを認識できるようになりました。
が、GameMap
は抽象クラスで、インスタンス化されることはありません。
なのにchangeMap
メソッドでGameMap
を引数に取っているのはおかしいですね。
この「GameMap
が引数」なのを「GameMap
の継承クラスを引数」にしましょう。
import { GameMap } from "./map";
class Game {
public map: GameMap | null = null;
public changeMap<T extends GameMap>(map: T) {
this.map = map;
}
}
これで、GameMap
の継承クラスを引数に取ることができるようになりました。
extends
というのは、クラスの継承の意味合いで、実際にGameMap
を継承した別のクラスを定義するときはclass NewGameMap extends GameMap {}
のように書きます。
で、T
です。
ちょっと前の記事でPromise<T>
も出てきましたね。
ジェネリクスとは
ジェネリクスを用いると、型の安全性とコードの共通化を両立することができます。
ジェネリクス (generics) | TypeScript 入門 『サバイバル TypeScript』
ジェネリクスは、型を概念的に扱うような感じで...うん...
じゃあ例えば引数として受け取った値をそのまま返す関数returnSuruze
を作ってみましょう。
const returnSuruze = (value) => {
return value;
};
この関数の処理がどんなのなのかは多分見ればわかると思います。
まぁ、今扱ってるのは TypeScript なので型を指定してあげましょう。
const returnSuruze = (value: string): string => {
return value;
};
はい、これで文字列を引数にして、文字列を返す関数になりました。
でもこれだと数字とかだとエラーが出ますね。
const returnSuruze = (value: string | number): string | number => {
return value;
};
型1 | 型2
みたいな表記は Union Types といって、型1
または型2
のどちらかを受け取ることができるようになります。
つまりこれは、文字列か数字を受け取って、文字列か数字を返す関数になります。
なんかいい感じに見えますよね。
const returnSuruze = (value: string | number): string | number => {
return value;
};
console.log(returnSuruze(2) + 4);
はい、returnSuruze
に数字を渡して、それがそのまま返ってきてその数値に 4 を足して...え?エラー?
演算子 '+' を型 'string | number' および 'number' に適用することはできません。ts(2365)
なぜなら、returnSuruze
が返すのはstring
かnumber
のどちらかで、それは決定しないからです。
これじゃあ不便ですよね?
え?こうすればいいじゃないかって?
const returnSuruzeString = (value: string): string => {
return value;
};
const returnSuruzeNumber = (value: number): number => {
return value;
};
console.log(returnSuruzeNumber(2) + 4);
なるほど。
これならエラーは起きませんね。
でも、これだと型の数だけ関数を作らないといけないですよね。
オブジェクトなども含めると少なくとも無限に関数を作らなければなりません。
そこで、ジェネリクスを使います。
const returnSuruze = <T>(value: T): T => {
return value;
};
console.log(returnSuruze(2) + 4);
<T>
というので、T
という型を作り上げます。現在、この型は何でも受け入れます。
そして、引数value
と返り値の両方にT
を指定しています。
そのため、引数value
と返り値は同じ型になります。
そのため、例で 2 というnumber
型を渡しているので、返り値の型もnumber
になる...というわけなのです。
先ほどは何でも受け入れると言いましたが、T
に制約を加えることもできます。
const returnSuruze = <T extends string | number>(value: T): T => {
return value;
};
これで、T
はstring
かnumber
のどちらかになります。
それでもvalue: string | number
の時とは異なり、返り値は引数value
と同じ型であることが保証されているため、エラーは起きません。
このT
は、自ら関数呼び出し時に指定することもできます。
console.log(returnSuruze<string>("Hello, World!"));
このように、関数名<ジェネリクス型>(引数)
という形で、いつもの関数呼び出しに少し手を加えるだけで、ジェネリクス指定も出来ます。
ちなみに、Type
の頭文字をとって良くT
と書かれますが、他の文字でも構いません。U
とかK
とかV
とか...。1 文字じゃなくても。
これでGameMap
の継承型を受け付けてる...?
そうなんですが、現在のコードでは「インスタンス化されたGameMap
を継承したクラス」が引数となります。
わざわざインスタンス化してから引数に渡してやるのは面倒ですよね。
(プログラミング的思考:「インスタンス化」という共通項目があるならそれを関数に落とし込むべきである)
なので、インスタンス化していない状態のGameMap
を引数に取るようにしましょう。
class Game {
public map: GameMap | null = null;
public changeMap<T extends GameMap>(map: new () => T) {
this.map = new map();
}
}
引数がnew () => T
となっています。
これは、「インスタンス化したらT
型になるクラス」というような意味合いです。
これで、GameMap
のインスタンス化前継承クラスを引数に取ることができるようになりました。
このGame
クラスは絶対外で使うので、export
しておきましょう。
export class Game {
public map: GameMap | null = null;
public changeMap<T extends GameMap>(map: new () => T) {
this.map = new map();
}
}
まとめ
これでオブジェクト指向プログラミングの基本的な概念と、昨今流行のジェネリクスについて学べましたね。
最近の多くの言語にはジェネリクスが搭載されているので、ぜひ使いこなせるようになっておきましょうね~