🚰

Effect.tsのpipe()関数は、なにか

に公開

Effect.tsでは、 Effect.EffectOption.Option など、さらに言えばあらゆるものに .pipe メソッドが生えています。 (パイプと読む)
すこし例を見てみましょう。

import { Option } from "effect";

const fakeFetchName = () => {
  switch (Math.floor(Math.random() * 3)) {
    case 1:
      return Option.some("Alice");
    case 2:
      return Option.some("Bob");
    default:
      return Option.none();
  }
};

console.log(
  fakeFetchName().pipe(
    Option.map((name) => `${name}さん、こんにちは`),
    Option.getOrElse(() => "誰もいませんね..."),
  ),
);

これを実行してみます。

> bun run <file>.ts
誰もいませんね...
> bun run <file>.ts
Aliceさん、こんにちは
> bun run <file>.ts
誰もいませんね...
> bun run <file>.ts
Aliceさん、こんにちは
> bun run <file>.ts
誰もいませんね...
> bun run <file>.ts
Bobさん、こんにちは

ここで、前提知識。まず、Optionというのは、T | nullのようなnullかもしれないもの、を扱う上でより一般性、または構造をもたせたものだと思っておいてよいです。
Option.mapは配列のmap (Array#map) のように考えることができます。
Array#mapは要素すべてに対し、関数適用して置き換えていきますね。Optionは要素が最大1の配列のように思うと、.mapだと思うことが…できそうではないでしょうか。
そう考えると、Option.getOrElsearr[0] ?? elseFn()のようなものです。[1]

Rustで書くと?

Rustにも組込みでOption型があります。

fn main() {
    let fake_fetch_name = || {
        match rand::random::<u8>() % 3 {
            1 => Some("Alice"),
            2 => Some("Bob"),
            _ => None,
        }
    };

    let result = fake_fetch_name()
        .map(|name| format!("{name}さん、こんにちは"))
        .unwrap_or_else(|| "誰もいませんね...".to_string());

    println!("{}", result);
}

.map,.unwrap_or_elseがEffect.tsのOption.map,Option.getOrElseにそのまま対応する。

なるほどと。メソッドチェーンのように、Option.mapなどを、.pipeで渡せばよいのだ、と。

しかしなぜ、標準のArrayのように、 arr.map((e) => ...) のようにメソッドを提供してくれてはいないのでしょうか。

pipeとはなにか

console.log(
  fakeFetchName().pipe(
    Option.map((name) => `${name}さん、こんにちは`),
    Option.getOrElse(() => "誰もいませんね..."),
  ),
);

これを見ると高度な抽象化が裏であるのか、と疑ってしまうかもしれません。

しかし、pipeはあまりにも、シンプルすぎるほどに、シンプルです。

Effect.tsには単なる関数版のpipeもあるので、その例を見てみます。
(なお、 fp-tsにも同様にpipeがありますが、同じものです)

最もシンプルな例 (1引数)

import { pipe } from "effect";
const v = pipe(
  3,
);
console.log(v); // 3

pipe(x)は恒等関数です。なにもせず返します。

const pipe1 = (x) => x;

2引数

import { pipe } from "effect";
const v = pipe(
  3,
  (x) => x * 2,
);
console.log(v); // 6

pipe(x, f)f(x)を返します。

const pipe2 = (x, f) => f(x);

3引数

import { pipe } from "effect";

const v = pipe(
  3,
  (x) => x * 2,
  (x) => x + 1,
);
console.log(v); // 7

pipe(x, f, g)g(f(x))を返します。

const pipe3 = (x, f, g) => g(f(x));

より一般に

const pipe1 = (x) => x;
const pipe2 = (x, f0) => f0(x);
const pipe3 = (x, f0, f1) => f1(f0(x));
const pipe4 = (x, f0, f1, f2) => f2(f1(f0(x)));
const pipe5 = (x, f0, f1, f2, f3) => f3(f2(f1(f0(x))));
const pipe6 = (x, f0, f1, f2, f3, f4) => f4(f3(f2(f1(f0(x)))));

pipe(x, f0, f1, f2, ..., fn)は、fn(...f2(f1(f0(x))))を返します。

分かってきたでしょうか。ここで、このpipeを実装してみましょう。(型付けは考えないことにします。)

再帰呼び出しを利用すれば、

const pipe = (x: any, ...fns: readonly ((arg: any) => any)[]): any => {
  const f0 = fns[0];
  if (f0 === undefined) {
    return x;
  }
  return pipe(f0(x), ...fns.slice(1));
};

もしくは、Array#reduceを利用して、

const pipe = (x: any, ...fns: readonly ((arg: any) => any)[]): any => {
  return fns.reduce((acc, fn) => fn(acc), x);
};

のように実装できますね。

そして、冒頭の例のような、メソッドとしてのpipeは、単にx.pipe(...)pipe(x, ...)と等価であるように作られているにすぎません

パイプライン演算子

pipe(パイプ)はパイプライン演算子と関連があります。パイプライン演算子は、F#や、他にもElixir、R言語にも備えられているようです。
Wikipediaにある例をそのまま借りますが、Elixerでは以下のようなコードを、

String.split(String.upcase(String.trim(" Hello, world "))) #=> ["HELLO," "WORLD"]

以下のようにパイプライン演算子を利用した形に書き換えることができます。

" Hello, world " |> String.trim |> String.upcase |> String.split #=> ["HELLO," "WORLD"]

ここで、|>がパイプライン演算子に相当します。さきほどの書き方を想像するなら、 pipe(" Hello, world ", String.trim, String.upcase, String.split) に相当することになりますね。

なにが嬉しいのか、というところについても考えてみましょうか。

オブジェクト指向とパイプライン演算子

少し広い範囲のセクション名ですが、オブジェクト指向が何であるかということはともかくとし、この二つを俯瞰すると美しい示唆が見えてきます。

オブジェクト指向におけるメソッド呼び出しは obj.say() のように書かれます。メソッドは実際のところは、オブジェクトを引数にとる関数です。
実際、Go言語ではその色が出た書き方をしますね。

type Person struct {
    Name string
}
func (p Person) Say() string {
    return fmt.Sprintf("はじめまして、%sです", p.Name)
}

引数の場所が多少特殊な形になっているものの、対象のオブジェクト自体もまた引数であるような関数です。

内部構造にアクセスできるから違いがある、と指摘があるとすれば、確かにそうですが、それはスコープの扱いの話であり、このメソッドについては単に関数だと思うことができます。

Swiftなどの言語によっては extension のような、メソッドをさらに特定のスコープで拡張するようなことができますね。

一方で、それがない言語、TypeScriptなどでは、 stringReverse(o: string): string だとか、そのオブジェクトの対象を先頭にとる関数を用意すのが主流です。

しかし、これはすぐにネストの深い、そして適用順序と逆順に関数が表れる読みにくいコードになります。

const output = stringReverse(
  stringStripEnd('Q.E.D.',
      stringStripStart('foo:', input),
  ),
);

なお、拡張の方法をもたない言語で、先頭の引数として対象のオブジェクトを取る関数設計、スキームのことを、 データファースト(Data First) と呼びます。
対比して、パイプライン演算子で利用する想定で、 (...args) => (self) => ... のような(関数を返す)関数として設計する方針を、 データラスト(Data Last) と呼びます。

なお、Effect.tsの各種の関数は、データファーストにも、データラストにも利用できるように作られています。
たとえば、 記事冒頭の fakeFetchName().pipe(Option.map((name) => `${name}さん、こんにちは`)) は、 Option.map(fakeFetchName(), (name) => `${name}さん、こんにちは`) とも書けます。

ローカルなメソッド

メソッドをローカルに利用したい、という考え方もできます。
多くの言語では、クラスを宣言し、そのメソッドを定義し、そのオブジェクトとそのメソッドは同時に既定し、それのみしかできません。

しかし、SwiftのExtensionや、Rustのtraitとimplを使う方法などでは、メソッド自体も関数のようにインポートして使ったり、特定のスコープでのみ使えるようにする、といったことが可能になります。

以下、Swiftのドキュメントにある例です。

extension Int {
    func repetitions(task: () -> Void) {
        for _ in 0..<self {
            task()
        }
    }
}
3.repetitions {
    print("Hello!")
}
// Hello!
// Hello!
// Hello!

この機能があることで、組込みのデータタイプを上述のように拡張して使えたり、特定のコンテキストでのみ有用なメソッド群を分離するといったことができます。

これに近いことをTypeScriptでやろうと思えば、やはりデータファーストな関数を使うことになりそうですが、そこにパイプラインがあれば、似たような書き心地を実現できそうです。(とはいえ、上記の 3.repetitions { ... } には敵わないかもしれませんが…。)

pipeの重要なメリット

pipeの方法のまず重要なメリットは、関数が適用する順番に表れる、評価したい順に表れる、というところにあります。
通常の関数適用の書き方だと、最後に使う関数が、最初に表れることになります。

g(f(x))

これにより、ネスト深すぎ問題も解消されます。

f5(
  f4(
    f3(
      f2(
        f1(
          f0(
            obj
          )
        )
      )
    )
  )
)

その他には、次のようなメリットがあります。

  • 対象のオブジェクトに細工せずに、拡張を再現できる
  • ネストを回避するために変数に再代入しまくる、といったことも考えられるが、それらすべてに別名をつける必要があり、それを回避できる (Rustのような同一スコープでのシャドーイングがあれば良いかもしれませんが)
  • メソッドではなくデータラスト関数を用いることのメリット
    • 純粋関数として明瞭な書き方ができる
    • ひとつのファイルにすべてを書く必要がなく、また、自由な粒度でまとめることができる
  • イミュータブルな書き方を自然に誘導できる
    • 単なる関数が破壊をするようなコードは書かないだろう
    • クラスメソッドだと破壊するのかどうか明示できないが、関数の引数であれば非破壊であるように取ることを宣言できる (readonly)
  • オブジェクト指向で登場した様々な方法論やテクニックを自然に汲むことができる (see #パイプライン指向)

ECMAScriptとパイプライン演算子

ECMAScript[2]は数々の仕様が追加されてきた言語です。では、パイプライン演算子をそもそも追加するという発想はあるのでしょうか。

これについては、まさに以下のようなプロポーサルが出ており、|>と直接書いてパイプライン演算子を使えるようにしよう、という提案がなされています。

https://github.com/tc39/proposal-pipeline-operator

しかし、このプロポーサルは実はかなり昔からあり、私もたまにウォッチしていますが、未だに執筆時点ではStage2までしか来ていません。

完了へ向かうための主な障壁は、構文をどのようにすべきか、というところがなかなか決まらないから、という風に見えます。これまでの ECMAScript の既存資産を活かせつつ、構文的にも競合しない…というような要件や、そもそも沢山の候補があるなかで、これであるべき、という決定打に欠けているのかもしれません。一度採用するとやりなおしが効かないのも、決定を難しくしている要因かもしれません。

イシューのBikeshedding the Hack topic token #91が言ってることが、まさにそのことの一端を表していますね。 (特殊トークンに関する議論をずっと、ああでもないこうでもない、とし続けている、みたいな話)

メソッドを生やさなくて良くなることと、シリアライズでの嬉しさとかについて (ポエム) (読みとばそう)

これは独自の考察なのですが、対象のオブジェクトに細工しなくても、オブジェクト指向のようなことができる、というのもひとつの利点かもしれません。

たとえば、zodでパースしたオブジェクトというのはなんらかの規約を守ったオブジェクトなので、その上でさらに特定の操作や計算をしたい、といったことがあります。
そうしたとき、zodでパースされたオブジェクトは、とはいえ(z.brandするとしても)単なる構造であるので、メソッド的に拡張するのは難しいか、泥臭いことになります。(実は独自でやってみたのですが、わりとツラいですね)
なんの話をしているんだという感じかもしれないですが、ちょっとコンテキストをそれなりに整理しないと大変で、そうまでしても得るものは特にないので忘れてもらってよいものです。

zodへの移行期的な考えもあるのですが、最初からやるのであれば、effect/schemaがあるので、そちらを利用しましょう。

パイプライン指向

ここから先の話は私もまだかなり入口にいるのですが、パイプライン指向というのをScott Wlaschinという方が中心に提唱されています。
この方は、少し前に開催された関数型まつり2025でもリモート発表をされ、その分かりやすさとOOPとの関連付けなどで評判が良かったです。と思います。

もし機会があれば、そのときの発表そのものではないですが、氏の関連した発表や、書籍をぜひ参照していただけると雰囲気が分かります。

https://speakerdeck.com/swlaschin/pipeline-oriented-programming

https://youtu.be/ipceTuJlw-M

ちょっと雑な所感を書きますが、とにかく、なんか、オブジェクト指向という形で導入されてきた諸処の概念は、プログラミングというものがどのように作られてきたか、というCPUやアセンブリの構成に強く依存してきてしまっていて、一度立ち返って整理すればそれらは綺麗に考えなおせる可能性があり、そのひとつがパイプライン指向の提唱の流れではないかと思います。個人の感想です。

宣伝: 一緒に未来へ

有効期限: 2025年09月30日 (適宜更新します)

いままでにないものを作ろうと本気で議論するというのはとても難しいですが、楽しいものです。

私はとにかく、気張らずに、楽に仕事がしたい。その要がEffect.tsかどうかは分からないですが、そうした"楽"を楽しく追求できる仲間を募集しています。ゆるくいきましょう。

私のチームを含め、いくつかのチームが採用をしています。採用ページ → https://recruit.optimind.tech/

あと、閲覧注意ですが、本当に僕が個人的に、今の会社についてと、コワーカー募集というnote記事を書きました。

脚注
  1. Option.Option<T>Tにはnull,undefinedも入れることができる点は異なりますので、正確にはarr.length === 0 ? elseFn() : arr[0]などがより適切でしょう。 ↩︎

  2. ECMAScriptというのは、JavaScriptのことです。 ↩︎

GitHubで編集を提案
OPTIMINDテックブログ

Discussion