Skip to content

Instantly share code, notes, and snippets.

@qsona
Last active November 13, 2022 05:20
Show Gist options
  • Save qsona/bf4cf9b211bdace58fc24a35c0b8aef5 to your computer and use it in GitHub Desktop.
Save qsona/bf4cf9b211bdace58fc24a35c0b8aef5 to your computer and use it in GitHub Desktop.
How to write polymorphic code with JavaScript's plain objects and functions (objects are immutable)

How to write polymorphic code with JavaScript's plain objects and functions (objects are immutable)

概要

JavaScript の Plain Object (ただの) と、関数のみを利用して、Polymorphism を実現したい。

とはいえ Polymorphism の定義は人によって違うので、以下の仕様とルールを満たすものとしたい。

そもそもそれは Polymorphism ではない、あるいは意味をなさない、というような反論があればそれも歓迎。

仕様

  • この世には Human と Dog の2種類の Animal が存在する。
  • Animal はすべて age (年齢, 整数)というデータが紐づく。
  • Animal からは normalizedAge という値が計算できる。
    • Humanの場合、ageと等しい
    • Dogの場合、ageの4倍である
  • この世に存在する Animal データセットが与えられる
    • JSON の配列である
    • 配列の各要素はtype(種類)とageを持つ
  • 上記のデータセットから、すべての Animal の normalizedAge の合計を出力せよ

ルール

  • 結果は main 関数で出力する
  • main 関数に if 文 (typeでの分岐)を書いてはいけない
  • normalizedAge を計算する関数に if 文 (typeでの分岐)を書いてはいけない
    • normalizedAgeにif文を書くということは、他の関数が追加されたときもそこにif文を書かなければならないことを意味する
    • これは Polymorphism に反する

参考実装

1_polymorphism.rb を参照のこと

require 'json'
def main
sum_age = Animal.all.map { |animal| animal.normalized_age }.sum
p sum_age # 10 + 5 * 4 = 30
end
class Animal
def initialize(age:)
@age = age
end
JSON_STRING = '
[
{ "type": "Human", "age": 10 },
{ "type": "Dog", "age": 5 }
]
'
def self.all
JSON.parse(JSON_STRING).map do |hash|
if hash['type'] == 'Human'
Human.new(age: hash['age'])
elsif hash['type'] == 'Dog'
Dog.new(age: hash['age'])
end
end
end
end
# Immutable
class Human < Animal
def normalized_age
@age
end
end
# Immutable
class Dog < Animal
def normalized_age
@age * 4
end
end
main()
// Plain Object と function だけを使って
// Polymorphism を表現するには
const main = () => {
const sumAge = allAnimals().map(animal => getNormalizedAge(animal)).reduce((memo, num) => memo + num);
console.log(sumAge); // 10 + 5 * 4 = 30
}
const allAnimals = () => {
return [
{ type: 'Human', age: 10 },
{ type: 'Dog', age: 5 },
];
};
const getNormalizedAge = (animal) => {
// ??
// This is not polymorphic!
if (animal.type === 'Human') {
return animal.age;
} else if (animal.type === 'Dog') {
return animal.age * 4;
}
};
main()
// see https://twitter.com/qsona/status/1352299390598217729
const main = () => {
const sumAge = allAnimals().map(animalData => Animal.getNormalizedAge(animalData)).reduce((memo, num) => memo + num);
console.log(sumAge); // 10 + 5 * 4 = 30
}
const allAnimals = () => {
return [
{ type: 'Human', age: 10 },
{ type: 'Dog', age: 5 },
];
};
class Animal {
static wrap(animalData) {
if (animalData.type === 'Human') {
return new Human(animalData);
} else if (animalData.type === 'Dog') {
return new Dog(animalData);
}
}
static getNormalizedAge(animalData) {
return this.wrap(animalData).getNormalizedAge();
}
}
class Human {
constructor(animalData) {
this.age = animalData.age
}
getNormalizedAge() {
return this.age;
}
}
class Dog {
constructor(animalData) {
this.age = animalData.age
}
getNormalizedAge() {
return this.age * 4;
}
}
main()
@yamitzky
Copy link

TypeScript の例ですが、、、なるべく関数型っぽい感じ(immutable なシンプルなデータ構造と関数)に寄せるとしたならば、こういう感じになるかなと思います

export type Animal = {
  readonly age: number
  normalizedAge: (age: number) => number
}

type PlainAnimal = { type: 'Human', age: number } | { type: 'Dog', age: number } 

export const allAnimals: () => Animal[] = () => {
  // e.g) fetch data from API
  const rawAnimals: PlainAnimal[] = [
    { type: 'Human', age: 10 },  
    { type: 'Dog', age: 5 },  
  ]

  // deserialize plain obj to obj
  return rawAnimals.map(animal => {
    switch (animal.type) {
      case 'Human':
        return { ...animal, normalizedAge: animalAge }
      case 'Dog':
        return { ...animal, normalizedAge: humanAge }
    }
  })
}
const animalAge = (age: number) => age * 4
const humanAge = (age: number) => age

// ------------------------

const main = () => {
  const animals = allAnimals()
  console.log(sumAge(animals)); // 10 + 5 * 4 = 30
}
const sumAge = (animals: Animal[]): number => {
  // polymorphic
  return animals.map(animal => animal.normalizedAge(animal.age)).reduce((memo, num) => memo + num);
}
main()

@itkrt2y
Copy link

itkrt2y commented Jan 22, 2021

yamitzkyさんの案もいいと思いますし、クラスっぽく書くとしてもplain objectでこんな風にしちゃえばいいと思います

function main() {
  const data: Data[] = [
    { type: "Human", age: 10 },
    { type: "Dog", age: 5 },
  ];

  const sum = data
    .map((data) => getanimal(data).getNormalizedage())
    .reduce((a, b) => a + b);

  console.log(sum);
}

type Data = { type: "Human" | "Dog"; age: number };
type Animal = {
  age: number;
  getNormalizedage(): number;
};
type NewAnimal = (data: Data) => Animal;

const newHuman: NewAnimal = (data) => ({
  ...data,
  getNormalizedage() {
    return this.age;
  },
});

const newDog: NewAnimal = (data) => ({
  ...data,
  getNormalizedage() {
    return this.age * 4;
  },

  // getNormalizedage()はそもそも関数じゃなくて以下のようにすれば良いのですが、そこはクラスっぽく書く例なのでご愛嬌
  // normalizedAge: data.age * 4
});

function getanimal(data: Data): Animal {
  switch (data.type) {
    case "Human":
      return newHuman(data);
    case "Dog":
      return newDog(data);
  }
}

main();

@qsona
Copy link
Author

qsona commented Jan 23, 2021

ありがとうございます。

確かに JS の性質として plain object に関数をはやせるので、それでも良いのですが、個人的にはこの例を見ると逆に class を使ってもいいんじゃないかと思っちゃいました。
例えば itkr2ty さんの例だと getNormalizedage 関数がオブジェクトごとに生成されてしまうので、関数の宣言自体は外に出しておいたほうがパフォーマンス上よいかなと思います。この辺は class を使うと内部的には prototype を使う感じで1つの関数が共通で使われるのでわざわざ気にしなくてもよくなります。

逆の見方をすれば、パフォーマンスは些細な話であり、class を適切に使えない (例: immutable にしておいて欲しい class に setter 系のメソッドが生やされてしまうなど) ことによる技術的負債のほうが許容できないと見る向きもあるかもしれません。

@itkrt2y
Copy link

itkrt2y commented Jan 23, 2021

getNormalizedage 関数がオブジェクトごとに生成されてしまうので、関数の宣言自体は外に出しておいたほうがパフォーマンス上よいかなと思います。

ここはコードコメントにも書いたとおりクラスっぽく書くためにわざとそうしたご愛嬌で、関数宣言を外に出すべきなのもそうですし、そもそも仕事ならばさくっと以下のように書きます

type Animal = "Human" | "Dog";
type NomalizeAge = (age: number) => number;

const animalMap: Record<Animal, NomalizeAge> = {
  Human: (age) => age,
  Dog: (age) => age * 4,
};

function main() {
  const data = [
    { type: "Human", age: 10 },
    { type: "Dog", age: 5 },
  ];

  const sum = data
    .map((data) => animalMap[data.type](data.age))
    .reduce((a, b) => a + b);

  console.log(sum);
}

main();

拡張性を求めるなら以下

import * as Human from "./human";
import * as Dog from "./dog";

type Animal = { normalizeAge: (age: number) => number };
type AnimalType = "Human" | "Dog";
type Data = { type: AnimalType; age: number };

const animalMap: { [key in AnimalType]: Animal } = { Human, Dog };

function main() {
  const data: Data[] = [
    { type: "Human", age: 10 },
    { type: "Dog", age: 5 },
  ];

  const sum = data
    .map((data) => animalMap[data.type].normalizeAge(data.age))
    .reduce((a, b) => a + b);

  console.log(sum);
}

main();
// human.ts
export const normalizeAge = (age) => age;
// dog.ts
export const normalizeAge = (age) => age * 4;

あと元ツイに関して、ポリモーフィズムはCでも関数ポインタを使えばいいだけなのでオブジェクト指向の特徴ではない、って話があります。僕はCに詳しくないのと、人によって意見が変わるところな気もするので、深入りはしませんが......。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment