🤔

メソッドやフィールドからドット演算子の型を考える(おまけでポインターも)

2024/10/22に公開

この記事の目的

今回の記事では普段あまり考えない式に登場する.(ドット演算子)の型についての独自の考えを述べていきます。ただし、これは「ドット演算子には型がある」という主張を行うものではなく、「もし、ドット演算子がこのような型を持つ場合には辻褄があう」程度の主張です[1]

そして、その上でドット演算子と辻褄があう型を持つような演算子を関数型言語で用いた場合のコードの比較を行い、関数型言語のアイデアとそれ以外の言語のアイデアの橋渡しとなることがこの記事の目的です。

メソッド・チェーン

まず初めに、この記事はJavaのインスタンス・メソッドのメソッド・チェーンを起点としてドット演算子を見ていきます。
クラス・メソッドなどに関しては扱いません[2]

Javaなどの言語では、時折「メソッド・チェーン」という書き方が登場します。

例えばStringBuilderクラスを使う時。

new StringBuilder().append("Hello").append(' ').append(username).append('!').toString();

例えばStreamインタフェースを使う時。

//numbersはList<Number>型
numbers.stream().mapToDouble(number -> 1 / number.doubleValue()).sum();

いま上にあげた二つの例は、どちらもメソッドを繋いでいく「メソッド・チェーン」と呼ばれる書き方を使っています。

メソッド・チェーンの特徴

さて、メソッド・チェーンが使える場面は「あるメソッドに対して先行するものはインスタンスである」という特徴があります。これは、メソッド・チェーンがあくまでもインスタンス・メソッドの仕組みを使ってつなげたものであるという点に注目すると明らかでしょう。

例えばStringBuilderのメソッド・チェーンの各段階ではこのようなインスタンスになっています。

new StringBuilder()	// StringBuilderのインスタンス
.append("Hello")	// StringBuilderのインスタンス
.append(' ')		// StringBuilderのインスタンス
.append(username)	// StringBuilderのインスタンス
.append('!')		// StringBuilderのインスタンス
.toString();

例えばStreamのメソッド・チェーンの各段階ではこのようなインスタンスになっています。

numbers							// List<Number>のインスタンス
.stream()						// Stream<Number>のインスタンス
.mapToDouble(number -> 1 / number.doubleValue())	// DoubleStreamのインスタンス
.sum();

これは明らかではあるものの、ドット演算子の型を考える上では確認しておく必要があるポイントです。

ドット演算子

Javaではインスタンスに関して、フィールドはinstance.field、メソッドはinstance.method(args...)のような形でアクセスすることができます。この時、インスタンスにアクセスするために使うのが.であり、これをドット演算子と呼びます[3]

しかしながら、ドット演算子は演算子であるものの、その型を意識したことはおそらくないでしょう(少なくとも私の知る(調べた)限りでは、ドット演算子を実際の演算子のように型を意識しようという試みは一般的ではないように感じました)。

ですが、この記事ではあえてドット演算子の型に踏み込んで考えてみたいと思います。

ドット演算子が持ってそうな型を考える

ここからは、ドット演算子の型をHaskell的な表記で書いていきます。

インスタンスに関して、ドット演算子は左側にインスタンス変数を、右側にフィールドやメソッドがきます。

まず、ドット演算子の左側にインスタンス変数が来るのはフィールドとメソッドどちらでも共通みたいなので、型シグネチャ的はこのようになりそうです。

-- Javaなど
(.) :: Object a => a -> ? -> ?

現状これは「ドット演算子は左からはインスタンス変数を受け取り、右からはなにかを受け取って、何かを返す」くらいの意味を示しています。

次に、ドット演算子の右側について考えてみましょう。ドット演算子の右側に来るフィールドやメソッドは左側にくるインスタンス変数の型に束縛されます。そして、フィールドならインスタンスの値を、メソッドならインスタンスの関数が返ってくると考えましょう。そうすると、ドット演算子の真の型が見えてきます。

-- Javaなど
(.) :: Object a => a -> (a -> b) -> b

これはすなわち、「ドット演算子は左からはインスタンス変数を受け取り、右からはインスタンス変数の型の値を受け取り適当な型の値や関数を返す関数を受け取り、適当な型の適当な値や関数を返す」ということを意味します。

具体例

具体例として、以下のような式を考えます。

numbers.stream()
  • numbersは型変数のaにあたりList<Number>
  • stream()は型変数の(a -> b)にあたりList<Number> -> Stream<Number>
  • numbers.stream()は型変数のbにあたりStream<Number>

以上の点に注意すると上で考えた型とドット演算子との辻褄が合うということがわかります。

このことを通じて見えてきたものとしては、ドット演算子を演算子として型があるものとして辻褄が合うように解釈をすると、フィールドもメソッドも値を返す関数であると考えることができそうだということです。

ドット演算子に対応するHaskellの演算子

さて、ここまではドット演算子を見てきましたが、ドット演算子に対応するHaskellの演算子はなんでしょうか。

その答えは&です。

(&) :: a -> (a -> b) -> b

実際には&演算子はaに関して型の制約がないのでより一般的なものですが、先ほど考えたドット演算子の型を満たします。

ドット演算子とHaskellの&の比較

さて、ここまでで示した内容を踏まえて、ドット演算子を使った書き方ととHaskellの&をつかった書き方を見比べてみましょう。

以下に示す二つは同じような処理のコードをそれぞれJavaとHaskellで書いたものです。

numbers.stream().mapToDouble(number -> 1 / number.doubleValue()).sum();
numbers & map (\number -> 1/number) & sum

Haskellの方では、stream()や引数を取らないメソッドなどに関していくつかの省略があるものの、似たような書き方になることがわかると思います。

まとめ

今回は、ドット演算子はa -> (a -> b) -> bのような型を考えると辻褄が合いそうであり、Haskellの&がそれに該当しそうだという話をしてきました。

また、これはJavaのクラスだけではなく構造体などにも適用可能な考え方です。

今回の記事を通じて、普段何気なく使っているようなものに対して、今までとは別の視点で考えるきっかけになれたら幸いです。

おまけ(ポインターとか)

C/C++のポインターに関してはこう書けるかもしれません。

(&) :: (a -> Pointer a)
(*) :: (Pointer a -> a)

アロー演算子の型がこのようなものだと考えると辻褄が合う解釈が可能でしょう。

(->) :: Pointer a -> (a -> b) -> b

ポインターで苦労していた人も「辻褄が合う型の解釈」をガイドにして考えると、うまくいくかもしれません。

型がそれほど強くない言語でも、プログラマが型について考えることで安全で見通しの良いプログラムを書けるようになるかもしれません。

脚注
  1. 私が知っている限り、ドット演算子は単にフィールドやメソッドにアクセスするためのものとして説明されるだけにとどまり、型が定義されているというわけではないです。 ↩︎

  2. クラス・メソッドなどに関してはインスタンス・メソットと考え方が異なるため。 ↩︎

  3. https://www.ibm.com/docs/ja/i/7.3?topic=expressions-dot-operator ↩︎

Discussion