Yuki's Tech Blog

仕事で得た知見や勉強した技術を書きます。

【TypeScript】【Ruby】TypeScriptとRubyのクラスの書き方を比較してみた part1

目次

背景

TypeScriptとRubyのクラスをよく書くのですが、ごっちゃになる時があるのでまとめます。

そもそもクラスとは

こちらの記事にまとめてます。

クラスとは、あるオブジェクトに関する変数と、あるオブジェクトに関する処理を一つにまとめたものです。

【オブジェクト指向設計実践ガイド】第2 章 単一責任のクラスを設計するを読んで学んだことをざっくりまとめてみた - Yuki's Tech Blog

クラスの定義方法

TS

TypeScriptでは classキーワードを用いてクラスを定義できます。

class User {};

const user = new User();
console.log(user); // => {}

Ruby

Rubyでも、同様にclassキーワードを用いてクラスを定義します。

class User
end

user = User.new
p user # => #<User:0xc7a>

インスタンスの生成方法

TS

TypeScriptでは、new演算子を用いてインスタンスを生成します。 また、クラスを定義すると、クラスと同名の型も定義されます。インスタンスを代入する変数に型アノテーションをしたい場合、このクラスの型を使用します。

Ruby

Rubyでは、Class#newを用いてインスタンスを生成します。

# [[c:Class]] クラスのインスタンス、C クラスを生成
C = Class.new   # => C

# [[c:Class]] クラスのインスタンス、C クラスのインスタンスを生成
C.new           # => #<C:0x00005623f8b4e458>

コンストラク

TS

コンストラクタは、クラスからインスタンスを生成するときに実行される関数です。コンストラクタには、インスタンスプロパティを初期化する処理を実装します。 TypeScriptでコンストラクタを使用するためには、constructorメソッドを定義します。new演算子でクラスを生成するときに、クラスの実引数に指定した値が、コンストラクタに渡されます。

class Person {

  // コンストラクタの本来の使い方ではないがこんな使い方もできる
  // コンストラクタの本来の使い方(メンバ変数の初期化処理)をしたい場合、事前にクラス内にメンバ変数を定義しておく
  constructor() {
    console.log("hoge");
  }
};

const user = new Person(); // => hoge

Ruby

Rubyではinitializeメソッドを利用します。

class User
  def initialize(name:)
    @name = name;
  end
end

user = User.new(name: "hoge")
p user # => #<User:0xe08 @name="hoge">

メンバ

TS

メンバとは、クラスやオブジェクト(インスタンス)の持つ変数や関数のことです。一般的には、インスタンスの変数や振る舞いは、メンバ変数、メンバ関数と呼ばれ、クラス自身の変数や振る舞いは、静的メンバ変数、静的メンバ関数と呼ばれます。言語によってはメンバ変数をプロパティ、メンバ関数をメソッドと呼んだりします。メンバは英語のmemberであり、構成員という意味です。つまり、メンバ変数とは、クラスに所属する構成員の変数という意味で解釈できます。

TypeScriptでインスタンスにプロパティを持たせたい場合、クラスの最初にメンバ変数を宣言する必要があります。しかし、メンバ変数を定義しただけだとエラーが出ます。理由は、new Person()を実行した際に、メンバ変数の値がundefinedになってしまい、メンバ変数がstring型の値ではないので整合性が失われてしまうからです。

class Person {
  // Property 'name' has no initializer and is not definitely assigned in the constructor.
  name: string
};

const user = new Person();
console.log(user); // => {}

そのため、整合性を保つためにもクラスにメンバ変数を宣言する際には、コンストラクタも定義しましょう。そうすることで、コンストラクタを通してメンバ変数を初期化できるので、このエラーを回避できます。

class Person {
  name: string;

  constructor(name: string) {
    this.name = name;
  }
}

const user = new Person("yuki");
const new_user = new Person(); // => Expected 1 arguments, but got 0.(2554)
console.log(user);

ちなみに、コンストラクタを定義する以外にもundefinedとの共用体型を定義するか、初期値を事前に設定するかのどちらかで、このエラーを回避できます。

■メンバ変数の型をundefinedとの共用体型にする

class Person {
  name: string | undefined;
}

const user = new Person();
console.log(user); // => Person: {}

■メンバ変数に初期値を設定する

class Person {
  firstName: string = "yuki";
  lastName: string = "haga";
}

class Product {
  // 初期値から型を推論できる場合、メンバ変数に型アノテーションをしなくても良い
  name = "fish";
  price = 800;
}

const user = new Person();
console.log(user);
// => [LOG]: Person: {
//      "firstName": "yuki",
//      "lastName": "haga"
//    } 

user.firstName = "hoge";
console.log(user);
// => [LOG]: Person: {
//      "firstName": "hoge",
//      "lastName": "haga"
//    } 


const fish = new Product();
console.log(fish);
// => [LOG]: Product: {
//      "name": "fish",
//      "price": 800
//    } 

これらの方法でもエラーを回避できます。しかし、インスタンス生成時に動的に初期値を指定できないので、やはりクラスにコンストラクタを定義したほうが嬉しいでしょう。

ちなみに、現状のコンストラクタの実装だと、メンバ変数が増えるたびにコンストラクタを修正する必要があります。これは以下のように、Object.assign()を利用することで防ぐことができます。Object.assign()は第一引数で指定したオブジェクトに、第二引数以降のオブジェクトの各プロパティを破壊的に追加・上書きしていくメソッドです。つまり、コンストラクタ内でthisを用いてインスタンスにアクセスして、そのインスタンスを、呼び出し時に指定したデータで上書きします。

type PersonType = {
  firstName: string;
  lastName: string;
  age: number;
}

class Person {
  firstName = "";
  lastName = "";
  age = 0;

  constructor(data: PersonType) {
    console.log(this); // メンバ変数に指定した初期値を持つインスタンスにアクセスできる(つまり、コンストラクタの処理より初期値の方が実行されるタイミングが早い)
    Object.assign(this, data);
  }
}

const user = new Person({ firstName: "yuki", lastName: "haga", age: 26 });

ちなみにですが、クラスから生成したインスタンスのメンバ変数を動的に増やしたりとかはできません(できないのが挙動として正しいので注意しましょう)。

// こういうのはできない
class User {
  // Property 'name' has no initializer and is not definitely assigned in the constructor.
  name: string = "";

  constructor(data: Partial<User> = {}) {
    Object.assign(this, data);
  }
};

const user = new User({ name: "hoge" });
console.log(user); // => { "name": "hoge" }
const new_user = new User({ name: "hoge", age: 26 });
// => Argument of type '{ name: string; age: number; }' is not assignable to parameter of type 'Partial<User>'.
//    Object literal may only specify known properties, and 'age' does not exist in type 'Partial<User>'.(2345)

Ruby

Rubyではメンバを事前に定義する必要はないですが、初期化時にどのメンバにどんな値を入れるかをinitializeで定義しておいた方が、わかりやすいです。余談ですが、関数を定義する際に、キーワード引数と普通の引数を併用することもできます。

class User
  def initialize(name, age: 0)
    @name = name;
    @age = age
  end
end

user = User.new("hoge")
p user # => #<User:0xe48 @name="hoge" @age=0>
user_1 = User.new("fuga", age: 20);
p user_1 # => # <User:0xf76 @name="fuga" @age=20>

メソッド

TS

以下のようにして、クラスにメソッドを定義できます。このクラスに定義したメソッドは、インスタンスの動作を表します。constructorメソッドもメソッドなので、メソッドはconstructorメソッドと同じように定義します。メソッド内でインスタンスにアクセスするためには、constructorメソッドと同じくthisを使います。このクラスに定義するメソッドは全てのインスタンスが持つメソッドなので、インスタンスが持つべきではない振る舞いをメソッドとして定義したり、ある状況特有の処理をメソッドとして定義したり、しないようにしましょう。また、メソッド呼び出し時は、関数呼び出しと同じように()をつける必要があるので、その点を注意しましょう。

type PersonType = {
  firstName: string;
  lastName: string;
}

class Person {
  firstName: string;
  lastName: string;

  constructor({ firstName, lastName }: PersonType) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

const user = new Person({ firstName: "yuki", lastName: "haga" });
console.log(user.fullName()); // => "yuki haga"

Ruby

Rubyではdefでメソッドを定義します。メソッド呼び出し時に引数を一つも指定しない場合、括弧を省略するのが一般的なので、注意しましょう。

class User
  attr_reader :first_name, :last_name
  def initialize(first_name, last_name:, age: 0)
    @first_name = first_name
    @last_name = last_name
    @age = age
  end
  
  def full_name
    "#{last_name} #{first_name}"
  end
end

user = User.new("yuki", last_name: "hoge");
p user.full_name # => "hoge yuki"

アクセス修飾子

TS

アクセス修飾子を用いることで、メンバ変数やメソッドに対してのアクセスを制限することができます。

アクセス修飾子 意味
(宣言なし) publicと同等
public どこからでもアクセス可能
protected 自身のクラスとサブクラスからアクセス可能
private 自身のクラスのみアクセス可能
type PersonType = {
  firstName: string;
  lastName: string;
}


class Person {
  private firstName: string;
  public lastName: string;

  constructor({ firstName, lastName }: PersonType) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

const user = new Person({ firstName: "yuki", lastName: "haga" });
console.log(user.lastName); // => "haga"
console.log(user.firstName);
// Property 'firstName' is private and only accessible within class 'Person'.(2341)

Ruby

Rubyにはメソッドに対してpublic, private, protectedなどがありますが、メンバに対してのアクセス修飾子はなかったような気がします(もちろんatttr_readerを定義しないとか、そういうやり方ならあります)。

privateメソッドをメソッドに指定することで、

  • クラスの内部でのみ使えるメソッド
  • レシーバを指定しないで呼び出すメソッド

になります。

メンバ変数のreadonly修飾子

TS

メンバ変数には、アクセス修飾子以外にも、readonly修飾子をつけることができます。readonly修飾子をつけることで、読み取り専用なメンバ変数を定義できます。読み取り専用なメンバ変数は、読み取り専用なので、メンバ変数宣言時に初期値を指定するか、コンストラクタで値を代入するかの2通りでしか値を代入できません

type PersonType = {
  firstName: string;
}

class Person {
  readonly firstName = "";

  constructor(data: PersonType) {
     Object.assign(this, data);
  }
}

const user = new Person({ firstName: "yuki" });
console.log(user.firstName); // => yuki
// publicだと代入できる
// Cannot assign to 'firstName' because it is a read-only property.(2540)
user.firstName = "hoge"; 

Ruby

Rubyでは、attr_readerを使えば読み取り限定のメンバとして定義できます。

getter

元々ゲッターとは、メンバ変数にアクセスするためのメソッドのことです。Typescriptではgetを使って、ゲッターを定義します。普通にメソッドを定義して、メソッド名の先頭にgetを指定するだけです。

個人的には、インスタンスのプロパティとして存在してもおかしくないようなものを、メソッドではなくゲッターとして定義すると良いのかなと思っています。 fullnameのようなインスタンスのプロパティのようなメソッドはgetterとして定義した方が、自然なのかなと思います。

class User {
  firstName: string = "";
  lastName: string = "";

  constructor(data: Partial<User> = {}) {
    Object.assign(this, data);
  }

  get fullName() {
    return `${this.lastName} ${this.firstName}`;
  }
};

const user = new User({ firstName: "yuki", lastName: "hoge" });
console.log(user.fullName); // => "hoge yuki" 

Ruby

Rubyでは、attr_readerを使えば読み取り限定のメンバとして定義でき、同時にゲッターも定義されます。full_nameのようなオリジナルのゲッターを定義したい場合、メソッドを定義すればOKです。

class User
  attr_reader :first_name, :last_name
  def initialize(first_name:, last_name:)
    @first_name = first_name;
    @last_name = last_name;
  end
 
  def full_name
    "#{last_name} #{first_name}"
  end
  
end

user = User.new(first_name: "yuki", last_name: "hoge")
user.full_name # => hoge yuki

参考記事

クラス変数 - Wikipedia

メンバとは - 意味をわかりやすく - IT用語辞典 e-Words

静的メンバ

アクセス修飾子 (access modifier) | TypeScript入門『サバイバルTypeScript』

Class#new (Ruby 3.2 リファレンスマニュアル)