nunulkのプログラミング徒然日記

毎日暇なのでプログラミングの話題について書きたいことがあれば書いていきます。

脱オブジェクト指向プログラミングについて

はじめに

この記事について

以下の記事を読んだ感想というか、ずっと思ってたこととリンクする部分があったのでメモしておきます。

この10年のプログラミング言語の変化 - 西尾泰和のScrapbox

それと、前提として、自分は20年以上前に手続き型からオブジェクト指向への転換期を経験していて、Microsoft Windows のプログラミングで Win32 API という C のライブラリと MFC という C++ のライブラリを両方並行して使ってた時代にオブジェクト指向の恩恵をたぶんに受けた実感があるので、オブジェクト指向の否定というよりは、シングルパラダイムにこだわる必要はないんじゃないかっていう問題提起です。

概要

オブジェクト指向プログラミングの問題点は以前から指摘されていて、有名なところでは以下の記事。

Why OO Sucks

あと、興味深く読んだのはこの辺の記事。

OOP Overkill - DEV Community 👩‍💻👨‍💻

Elixir から Elm の流れで、いよいよオブジェクト指向に対する懐疑心が無視できないレベルに達した2017年冬。 – ゆびてく

「オブジェクト指向神話からの脱却」という特集をWEB+DB PRESSで書きました - きしだのHatena

個人的に問題だと思っているのは、

  1. 不適切な継承によって、不適切な共通処理が生まれるリスクがあるのではないか
  2. 状態を持たないクラスは関数でいいはずで、クラス化するのは無駄ではないか
  3. this がミュータブルにしかできない言語では、this の濫用を誘発するのではないか
  4. クラス=ファイルの制約を課している(あるいは推奨している)言語では、モジュールに柔軟性を欠くのではないか

の4点です。

自分はメインで PHP を使っているので上のような課題感を持っていますが、JavaRuby でも似たような感じだと思っていて、Service クラスみたいな、状態を持たないモジュールもクラスにせざるを得ないのが長年不満でした。

継承より合成

1 に関しては継承より合成(composition over inheritance)が良いというのはだいぶ浸透してきているものの、従来継承で表現されたような処理を合成でやると命令を委譲先へ流すだけの処理が大量に生まれる可能性があって、この1点でもって「わかっちゃいるけどやめられない」状態になってしまいます。Go の method forwarding のような仕組みがあればいいんでしょうけど、他のメジャーな言語にはありません。

package main

import "fmt"

type Player struct {
    name string
}

type CpuPlayer struct {
    Player // 型だけ指定すると勝手に委譲される
}

func (player Player) Name() string {
    return player.name
}

func NewCpuPlayer(name string) CpuPlayer {
    return CpuPlayer { Player { name }}
}

func main() {
    player := NewCpuPlayer("CPU1")
    fmt.Println(player.Name()) // "CPU1"
}

これを PHP でやろうと思うと、こうする必要があります。

<?php

class Player
{
    public function __construct(private string $name) {}
    public function name(): string
    {
        return $this->name;
    }
}

class CpuPlayer
{
    private Player $player;
    public function __construct(private string $name)
    {
        $this->player = new Player($name);
    }
    public function name(): string
    {
        return $this->player->name();
    }
}

$player = new CpuPlayer("CPU1");
echo $player->name() . PHP_EOL; // "CPU1"

上の例では委譲するメソッドがひとつなのでまだいいですが、これがたくさんあるととてもやってられないですよね。マジックメソッド __call を使う手もありますが、どうしても安全性に不安が出ます。

<?php

class Player
{
    public function __construct(private string $name)
    {
        //
    }

    public function name(): string
    {
        return $this->name;
    }
}

#[Attribute]
class Derive
{
}

trait Deriving
{
    // 中身はやっつけで書いたので適当
    private function getPropertyToDerive(string $name): ?string
    {
        $ref = new ReflectionClass(__class__);
        $props = $ref->getProperties();
        foreach ($props as $p) {
            $attributes = $p->getAttributes(Derive::class);
            if (count($attributes) === 0) {
                continue;
            }
            $propName = $p->getName();
            if (method_exists($this->$propName, $name)) {
                return $propName;
            }
        }
        return null;
    }
}

/**
 * @method string title()
 */
class CpuPlayer
{
    use Deriving;

    #[Derive] // #[Derive("name", "another", ...)] とかしてもいいかも
    private Player $player;

    public function __construct(private string $name)
    {
        $this->player = new Player($name);
    }

    public function __call($method, $args)
    {
        if ($p = $this->getPropertyToDerive($method)) {
            return $this->$p->$method();
        }
    }
}

$player = new CpuPlayer("CPU1");
echo "Player: " . $player->name() . PHP_EOL; // "CPU1"

まぁ、こんなことするくらいなら愚直にぜんぶ書くわって人もいると思います。

自分はそもそも Go はなんにもわからないので、method forwarding はできるけどアンチパターンかもしれませんし。

いずれにせよ、言語レベルで合成(委譲)がサポートされてなければ継承でいいやってなる気がします。

継承の問題点は、親子で処理を共有している場合、親クラスの変更が子クラスに影響を与えることで、個人的にはそれを避けて継承すれば問題ないとは思いますが、Go にしても Rust にしても、比較的新しめの言語が継承をサポートしなくなっていることを考えると、危険物(注意して取り扱うもの)なんでしょう。

クラスより関数

2 に関しては、無駄ではあっても弊害はないので、仕方なく毎度クラス化しています。

とはいえ、Service クラスに状態を持たせてしまう人はいると思うので、クラスしかモジュールを定義できないってなると、どうしてもクラス化に思考が引っ張られてしまう気がします。

うまい例が思い浮かばなかったですが、過去に複雑な Service クラスに状態を持たせてしまったせいで、どこで状態が変わったのかわからずデバッグに苦労したことがありました。

<?php

class SomeService
{
    // 他にもステップに応じて変化する状態がある
    public function __construct(private int $step, /* ... */) {}
    public function firstStep(): void
    {
        $this->step = 1;
        // 他にもステップに応じて変化する状態がある
    }
    public function secondStep(): void
    {
        $this->step = 2;
        // 他にもステップに応じて変化する状態がある
    }
}

$someService = new SomeService();
$someService->firstStep();
// 間に別のことをやる
$someService->secondStep();
// どこで状態が変わったのかわからなくなってしまう

できるだけ一時変数を使わない、というのもこれに類することだと思いますが、とにかく安易に状態を持たせるのがよろしくない、というのは感じるところです。

これに関しては、パッと普遍的な改善策が浮かばないですが、過去に状態を持つ Service クラスに遭遇したとき施したリファクタリングでは、それらの状態変数が関連するエンティティから導出できるものだったので、そちらに差し替えることで複雑度を下げました。

個人的には、クラス化しか選択肢がないことが、不要な状態変数をプロパティとして持たせてしまうことを誘発するのではないかと思っていますが、クラスでもいいし関数でもいい、となったところで、状態変数を持たせたいという欲求に抗えず、関数でいいところをクラスにしてしまうことは発生してしまうかもなとも思うので、この点については、けっきょくは属人的になってしまうのかなという気もしています。

Service みたいな振る舞いを表現したクラスは関数でいいはずで(カプセル化の必要がないので)、テストでモックしたいみたいな場合でも、関数が一級市民であれば、関数を DI すればいいはずです。

振る舞いも無理やりオブジェクトで表現する必要があるからこういうことが起こるのでは?という疑念があります。

this のミュータビリティ

これも「クラスより関数」と似たような感じですが、this が常にミュータブルであることにより、気軽に状態を変えてしまうマインドセットにならないだろうか、という懸念です。

Rust だと mut キーワードによって明示的にミュータビリティを指定できるし、仮に関数内で変更が行われていないのに mut が指定されていたらコンパイラが警告してくれたりします。

常に、「できるだけイミュータブル」「これはミュータブルにしたほうがいい」みたいな思考をコードに反映できるので気に入っています。

#[derive(Debug)]
struct Value {
    val: usize
}

impl Value {
    fn add(self: &mut Self, v: usize) {
        self.val += v;
    }
}
let mut val = Value { val: 0 };
val.add(1);
println!("{:?}", val); // Value { val: 1 }

(上の例ではイミュータブルにするほうがいいですが、例によっていい例が思い浮かびませんでした。あくまでも mut キーワードの使い方だけ見てもらえれば)

変数や仮引数のミュータビリティを指定できない言語では、目視であるいは意識で切り分けしないといけないので、めんどくさい部分はありますが、まぁ、注意して書けばそれほど大きな問題にはならないでしょう。

PHP には最近(8.1?) readonly プロパティが入ったので、進化はしていますね。

モジュールの柔軟性

Java とかだとインナークラスとかあってもうちょっと柔軟ですが、PHP にはないので(言語レベルで規制されてるわけではないですが)1クラス=1ファイルになってしまいます。

ひとつのクラスからしか使われないモジュールをクラス化するときでもファイルを分けますが、本当に「このクラスからしか使ってほしくない」というやつはコード規約を破って同居させたこともあります(コメントを添えて)。

「クラスより関数」をやりたくてもこれのせいでできないわけで、Java のような純粋なオブジェクト指向言語ならまだしも、PHP はいちおうマルチパラダイムなのに、純粋な関数はほぼ使わなくなってしまいました(別に使ってもいいんですが、名前空間とかあるので色々めんどくさい)。

あまり柔軟にしても制御が大変になったりするので、一長一短ありますが、ちょっとかゆいところに手が届かない感は感じてしまいます。

おわりに

とりとめもない感じになってしまいましたが、ここ数年色んな人のコード(とくに不具合が混入しやすいもの)を見たときに、これってオブジェクト指向のせいじゃね?と思ったことがあったので、その遠因となっていそうな事柄をまとめてみました。

書いてて、ぜんぶオブジェクト指向のせいじゃないかもという思いもよぎりつつ、Nim、Go、Rust あたりのわりと新しめの言語設計を見るにつけ、脱オブジェクト指向へ向かう動機というか、課題意識みたいなものが表れてるんじゃないだろうか、という思いもあるので、自分の考えをまとめてみました。

多態(動的ディスパッチ含む)はどんなパラダイムでも必要な機構であろうとは感じていて、Clojure の Protocol とか Rust の Trait とか、純粋なオブジェクト指向言語でなくても(Clojure は関数型に分類されるだろうし)そういうのはオブジェクト指向から生まれた概念だと思うので、みんないいとこ取りしていきましょう、っていう気持ちです。

Rust は最近触り始めてなかなか面白いので別途記事を書くつもりです。

本文とはあんまり関係ないですが、オブジェクト指向についての本でおすすめなのを1冊選べと言われたらこれを挙げます。