以前Javaでちょこっと勉強したGenericsですが、汎用的な考えで、他の言語でも当然使えるので、この記事で、型パラメータの基本を復習しておこうと思います。
言語はTypeScriptで書いてます。
1. 基本的な型パラメータの使用法
型パラメータは、クラスやメソッドを定義する際に、使用する型を後から指定できるようにする仕組みです。
実際の開発では、継承関係などを利用して型を制限することが多くあります。これにより、特定のメソッドやプロパティが存在することを保証できます。
基底クラスの定義
class Base {
private fieldName: string = '';
getFieldName(): string {
return this.fieldName;
}
setFieldName(fieldName: string): void {
this.fieldName = fieldName;
}
}
型制限を加えたジェネリッククラス
class Sample<T extends Base> {
private data: T;
constructor(data: T) {
this.data = data;
}
showMessage(): void {
// Baseクラスにあるメソッドなので使用可能
console.log(this.data.getFieldName());
}
}
使用例
class Hoge extends Base {
private value: string = '';
setValue(value: string): void {
this.value = value;
}
}
class Foo extends Base {
private value: number = 0;
setValue(value: number): void {
this.value = value;
}
}
const hoge = new Hoge();
hoge.setFieldName("name");
hoge.setValue("hogehoge");
const foo = new Foo();
foo.setFieldName("height");
foo.setValue(60);
const sample1 = new Sample(hoge);
sample1.showMessage(); // "name"
const sample2 = new Sample(foo);
sample2.showMessage(); // "height"
要は
・Sampleに対して、型の制約を持たせることができる
というメリットができます。
前回よりは少し実践的になりましたが、まだまだメリットがよくわかりませんね・・・
たとえば普通の継承とどう違うの?といった点ですね。
もう少し実践的な例を。
2. 型パラメータが必要となる実践的なケース
例えば商品マスタと顧客マスタの画面を作成する場合を考えてみましょう。機能が似通っている場合、共通の処理は抽象クラスで定義したほうが、コーディング量が少なくなり、保守性も向上します。
しかし、JavaやTypeScriptなどの静的型付け言語では、異なる型(ProductとCustomer)に対して共通の処理を型安全に実装するために、ジェネリクス(型パラメータ)が必要です。これにより、1つの抽象クラスで複数の具体的な型に対応でき、かつコンパイル時に型チェックが行われます。
エンティティクラスの定義
abstract class BaseEntity {
abstract getId(): number;
abstract setId(id: number): void;
}
class Product extends BaseEntity {
private id: number = 0;
private name: string = '';
private price: number = 0;
getId(): number {
return this.id;
}
setId(id: number): void {
this.id = id;
}
getName(): string {
return this.name;
}
setName(name: string): void {
this.name = name;
}
getPrice(): number {
return this.price;
}
setPrice(price: number): void {
this.price = price;
}
}
class Customer extends BaseEntity {
private id: number = 0;
private name: string = '';
private email: string = '';
getId(): number {
return this.id;
}
setId(id: number): void {
this.id = id;
}
getName(): string {
return this.name;
}
setName(name: string): void {
this.name = name;
}
getEmail(): string {
return this.email;
}
setEmail(email: string): void {
this.email = email;
}
}
抽象サービスクラス
abstract class AbstractService<T extends BaseEntity> {
constructor(private entityClass: new (...args: any[]) => T) {}
createNew(): T {
return new this.entityClass();
}
find(id: number): T | null {
// 実際にはDBから取得するが、ここでは新規インスタンスを返す例
return new this.entityClass();
}
findAll(): T[] {
console.log('Finding all entities');
return [];
}
update(entity: T): void {
console.log(`Updating entity with ID: ${entity.getId()}`);
}
delete(id: number): void {
console.log(`Deleting entity with ID: ${id}`);
}
}
具象クラスの実装
class ProductService extends AbstractService<Product> {
constructor() {
super(Product);
}
// 商品固有のメソッド
findByPriceRange(minPrice: number, maxPrice: number): Product[] {
console.log(`Finding products between ${minPrice} and ${maxPrice}`);
return [];
}
}
class CustomerService extends AbstractService<Customer> {
constructor() {
super(Customer);
}
// 顧客固有のメソッド
findByEmail(email: string): Customer | null {
console.log(`Finding customer by email: ${email}`);
return null;
}
}
3. クラスインスタンスの登録
このように共通のクラスを使うようなサービスクラスを使いたい時、クラスインスタンス(Class<T>)を登録する方が有用です。
abstract class AbstractService<T extends BaseEntity> {
private entityClass: new () => T;
constructor(entityClass: new () => T) {
this.entityClass = entityClass;
}
// entityClassを使用して新しいインスタンスを生成可能
createNew(): T {
return new this.entityClass();
}
}
class ProductService extends AbstractService<Product> {
constructor() {
// コンストラクタでAbstractServiceのコンストラクタを呼び出し
// 具体的な型情報を登録
super(Product);
}
}
4. メソッド単位での型パラメータ
クラスレベルで定義した型パラメータとは別に、メソッド単位で異なる型を使用したい場合があります。
DTOクラスの活用例
商品マスタに値を登録する際、Productの他にProductDTOなどの別のクラスを使用するケースを考えます。
例えば、ProductにmakerId(メーカーID)を持たせている場合、画面表示時には具体的なメーカー名が必要になります。この場合、Productを拡張したProductDTOを使用します。
class ProductDTO extends Product {
private makerName: string = '';
getMakerName(): string {
return this.makerName;
}
setMakerName(makerName: string): void {
this.makerName = makerName;
}
}
メソッドレベルの型パラメータ
class EnhancedService<T extends BaseEntity> extends AbstractService<T> {
constructor(entityClass: new () => T) {
super(entityClass);
}
// メソッド単位で新しい型パラメータT1を定義
protected findEntity<T1 extends BaseEntity>(
entity: T1,
conditions: Record<string, any>
): T1[] {
console.log('Finding entities with conditions:', conditions);
return [];
}
// 複数の型パラメータを使用
protected convertEntity<TSource extends BaseEntity, TTarget extends BaseEntity>(
source: TSource,
targetClass: new () => TTarget
): TTarget {
const target = new targetClass();
target.setId(source.getId());
return target;
}
}
使用例
class ProductServiceEnhanced extends EnhancedService<Product> {
constructor() {
super(Product);
}
findProductsWithDetails(conditions: Record<string, any>): ProductDTO[] {
const productDTO = new ProductDTO();
// メソッド単位の型パラメータを使用
return this.findEntity(productDTO, conditions);
}
convertToDTO(product: Product): ProductDTO {
return this.convertEntity(product, ProductDTO);
}
}
使い所が少々難しいですが、うまく使ってあげることで、以下のような場面で強力な武器となります:
- 再利用性の向上: 同じロジックを異なる型で使用可能
- 型安全性の確保: コンパイル時に型エラーを検出
- 抽象度の高い設計: 共通処理を抽象クラスに集約
- 保守性の向上: コードの重複を削減
型パラメータを理解し、適切に活用することで、より保守性が高く、拡張性のあるコードを書くことができます。
使いこなすのは大変かもですが・・・