1 of 26

Go のジェネリクス調査

2023-07-14 Tetsuya Morimoto

2 of 26

概要

  • ジェネリクス (Generics) とは
    • ジェネリクスはなにが嬉しいの?
    • ジェネリクスよくあるコメント
    • Go におけるジェネリクス
  • Go 1.18/1.20 のジェネリクス
    • Go に導入された新たな概念
    • ジェネリクスがうまくいくパターン
  • ucidm で実際に使っているジェネリクスのコード
  • まとめ

Go に足りないと散々言われ続けたもの

  • Goのなぜ問答 をみても (私の感覚では)�ほかに大きなものはなさそう?

3 of 26

ジェネリクスとは

4 of 26

ジェネリクス (Generics) とは

  • Generic programming という�プログラミングスタイルの1つ
    • 1977年に Ada で導入されて主流になった?
  • 型をパラメーター化する機能のこと
  • Polymorphism の3つの分類のうち、�Parametric polymorphism�(パラメーター多相)�を実現する機能のこと

public class Stack<T> {

​

private LinkedList<T> stack;

​

public Stack() {

stack = new LinkedList<>();

}

​

public void push(T item) {

stack.addFirst(item);

}

​

public T pop() {

if (isEmpty()) {

return null;

}

return stack.removeFirst();

}

​

public int size() {

return stack.size();

}

}

Java で書いた�パラメーター多相のコード例

5 of 26

ジェネリクスはなにが嬉しいの?

一般論はインターネットをググって調べて、ここではもりもとの経験から

  • 汎用的な処理を少ないコード量で実装できる
    • 身近な事例だと、コレクション型 (リスト、マップ等) の要素を扱うとき
    • コード量は少ない方が保守コストが下がる
  • パラメーター化しても、コンパイル時に型チェックができる
    • 制約を課すことでコンパイル時に型エラーを検出できるので品質があがる
    • 一般論として、実行時エラーよりもコンパイルエラーの方が推奨される
      • 実行時エラーを検証するのはテストしかない (相対的に高コスト)
  • アプリケーションが一定の規模を超えるとアーキテクチャが重要になってくる
    • ミドルウェアやフレームワークでジェネリクスは多用される
    • アプリケーションにおいても、Layered Architecture ではレイヤー間の境界をまたぐ汎用のデータ型を定義したりする
      • データ型の実装を後から変更できる仕組みの方が開発コスト (保守コスト) が下がる
      • データ型がインターフェースならば、インターフェースにメソッドを追加することで�実装を強制したり、変更をアプリケーション開発者へ示唆できる (コンパイルエラー)

メリット

  • 保守コストが下がる
  • 品質が向上する

デメリット

  • 学習コストが高い

現代のプログラミング�の設計は分割統治

6 of 26

ジェネリクスでよく聞くコメント

  • ジェネリクスは難しい
    • 正しい
  • ジェネリクスは難しくて開発が遅くなるから使わない
    • 間違い
    • 誤ったプログラムをコンパイルできる方が問題 → 実行時エラーになるのでもっと遅れる
    • ジェネリクスを理解できない開発者はコンパイルが通らなくてよい
  • ジェネリクスはライブラリ開発者が使う機能だ
    • 概ね正しい
    • アプリケーション開発者はジェネリックに定義されたフレームワークやライブラリを使う方が多い
    • アプリケーションよりも低いレイヤーでモジュールを開発するときに便利な機能
  • モダンな静的型付き言語はジェネリクスが言語機能として提供されている
    • たぶんそうかも?
    • Go 言語はジェネリクスがないと散々揶揄された (´・ω・`)

7 of 26

ジェネリクスのジレンマ

The Generic Dilemma Posted on Thursday, December 3, 2009. Go 言語の開発チームの Russ Cox 氏のブログから引用

C 言語の方法

  • void * を用いること
  • (型が分からないと人間向けの情報不足から?) プログラミングが遅くなる
  • プログラミング言語に複雑さは加えない

C++ 言語の方法

  • テンプレートを使う
  • コンパイル時の特殊化、マクロ拡張などで多くのコード生成を行う
  • コンパイル速度が遅くなる

Java の方法

  • すべてを暗黙のうちにボクシング (Boxing, 値→オブジェクトに変換) すること
  • Java のコードは小さくなるが、時間と空間 (データ量) の両方で実行時パフォーマンスが悪くなる
  • 例:Vector<Byte>は1バイト分が1バイトより大きい

Go の開発者はジェネリクスに関心がないとか、

Go の開発者はジェネリクス知らないとか (ひどい) 、

2009年から検討していてそんなことはない

8 of 26

Go におけるジェネリクスの提案

10年以上の検討の歴史

9 of 26

Go におけるジェネリクス

ジェネリクスの実装は一般的に2つに大別される

  • C++ 型
    • パラメーターとして渡された型の分だけ実装を生成
    • Go はこっちのタイプを採用
      • Java のジェネリクスとの比較
        • Java のジェネリクスに関する不満の多くは型消去に起因
        • Covariant: 共変と Contravariance: 反変という概念はない → 複雑さを軽減
      • C++ のジェネリクスとの比較
        • 私が C++ 言語を知らない (わからない) ので割愛
      • Go らしいシンプルな軽いジェネリクスの設計となっているらしい
  • Java 型
    • 実行時に型情報を削除 (Type Erasure: 型消去)し、Object 型のリフレクションによって実現

List<? extends Number>

List<? super Number>

詳細は記事を読んで

10 of 26

Go 1.18/1.20 Generics

11 of 26

(Go 1.18) 導入された概念

  • 型パラメーター
    • (ジェネリクス一般の概念/用語)
    • ジェネリックな型や関数を定義するために必要
  • 型引数
    • (ジェネリクス一般の概念/用語)
    • ジェネリックな型や関数に対して�具体的な型を指定するために必要
  • インターフェースによる制約 (Type constraint)
    • 新しい定義済み識別子: any, comparable
  • 型セット (Type sets)
    • このバージョンでインターフェースの概念が変わる

この4つの概念を詳細にみていく

func SumNumbers[K comparable, V Number](m map[K]V) V

型パラメーター

SumNumbers[string, int64](ints)

SumNumbers[string, float64](floats)

型引数

制約

type Number interface {

int64 | float64

}

型セット

12 of 26

ジェネリクスのチュートリアルをやってみよう

​

  • サンプルコード
  • 実際のサンプルコードをみながら解説

​

13 of 26

インターフェースのセマンティクスの変更

  • 1.17 までのインターフェース: メソッドの集合

​

  • 1.18 以降のインターフェース: 型の集合 => Type sets
    • 完全な後方互換性をもって移行できる
    • もともと interface{} の用途の1つに�OOP で言うところの object 型 (any) のように使われていた

type any = interface{}

type Reader interface {

Read([]byte) (int, error)

}

type myType interface {

~int | ~string

io.Writer

}

14 of 26

型パラメーターへの制約

  • インターフェースを使って型パラメーターに制約を課すことができる
  • コードを書いていて混同するとき
    • 型パラメーターは静的に型が決まる
      • 理屈上は速度面では速くなりそう?サイズ面では大きくなりそう?
      • 一方で 1.18 時点では Ian Lance Taylor は否定している (後述)
    • インターフェースは動的に型が決まる
  • サンプルコード

​

15 of 26

(Go 1.20) 組み込みの制約

  • 詳細は All your comparable types を参照
  • any は interface{} のエイリアス
  • comparable は == で比較可能な型を表す
    • 組み込みの制約として提供されている (型パラメーターでのみ使える)
    • 歴史的経緯により、この制約を定義することは不可能である
      • インターフェースの型によっては比較すると実行時に panic を引き起こす
      • Go は演算子のオーバーロードができない
    • 具体的な用途の1つとして、マップのキーにできる型を表す
  • Go 1.20 で型パラメーターの 制約に例外条項 を設ける
    • comparable を適切に使わないと厳密に静的な型安全ではなくなった?
    • any と comparable の微妙な関係がある
  • サンプルコード

(Go 1.20) 型の制約を満たすルール�

T implements C; or

C can be written in the form interface{ comparable; E },

where E is a basic interface� and T is comparable and implements E.

16 of 26

型セット (Type sets)

  • https://tip.golang.org/ref/spec#Interface_types
  • インターフェースがメソッドの集合から型の集合になった
  • インターフェースの要素が拡張された
    • 代数的データ型の直和型のようなものを記述できるようになった 例) int | string
    • Underlying Type を開発者が記述できるようになった

インターフェースの定義

​

InterfaceType = "interface" "{" { InterfaceElem ";" } "}" .

InterfaceElem = MethodElem | TypeElem .

MethodElem = MethodName Signature .

MethodName = identifier .

TypeElem = TypeTerm { "|" TypeTerm } .

TypeTerm = Type | UnderlyingType .

UnderlyingType = "~" Type .

17 of 26

Underlying Type (1)

  • 初めて聞く用語、私は知らなかった
  • もともと Go のコンパイラの内部実装には存在していたが、�ジェネリクスによってそれが表舞台に出てきた
  • すべての型は Underlying type を持つ
  • 無理やり OOP 言語でイメージすると親クラスに近いもの
    • Java では Integer 型の祖先を辿ると Object 型に行き着く
    • Python では int の親クラスは object 型になる
    • Go では int の Underlying type は int になる
      • 型階層という概念がないから親クラスではない

>>> inspect.getmro(int)

(<class 'int'>, <class 'object'>)

18 of 26

Underlying Type (2)

  • 入門Go言語仕様 Underlying Type / Go Language Underlying Type から引用
  • なぜ重要なのか
    • Assignability (代入可能性) の条件に使われている
  • 3つのルール
    • predeclared な boolean, numeric, string の underlying type は自分自身
    • 型リテラルの underlying type は自分自身
    • 型定義すると、左側と右側で underlying type を共有する
  • サンプルコード

19 of 26

Underlying Type (3)

  • https://go.dev/talks/2009/go_talk-20091030.pdf から引用
  • Go 言語の言語設計に大きな影響を与えている
  • 型階層は複雑化しがちという課題に対する Go 言語の答え
    • 型階層をもたない

Hierarchy is too stringent:� Types in large programs do not easily fall into hierarchies.� Programmers spend too much time� deciding tree structure and rearranging inheritance.

Reduce typing.� Keep the type system clear. No type hierarchy.� Too clumsy to write code by constructing type hierarchies.� It can still be object-oriented.

20 of 26

ジェネリクスがうまくいくパターン (1)

When To Use Generics by Ian Lance Taylor から引用

型パラメーターはいつ使うか?

  • 言語が定義しているコンテナ型を使うとき
  • 汎用のデータ構造
  • 型パラメーターをもつ場合はメソッドよりも関数を優先
    • 型にメソッドを追加するよりも、メソッドを関数にする方がはるかに簡単
    • (補足)
    • proposal: spec: allow type parameters in methods · Issue #49085
    • レシーバーは型パラメーターをもてるが、メソッドは型パラメーターをもてない
    • もともと Go のメソッドはインターフェースを実装しているかどうかをチェックするもの
      • パラメーター化されたメソッドがインターフェースを実装しているかどうか�を合理的にチェックできるのか?
  • 共通メソッドの実装

シンプルなガイド

  • 型だけ異なるまったく同じ�コードを書いているとき

21 of 26

ジェネリクスがうまくいくパターン (2)

When To Use Generics by Ian Lance Taylor から引用

型パラメーターを使うべきではないとき

  • インターフェースの型を�型パラメーターで置き換えない
    • インターフェースを扱う関数の方が可読性は高い
    • 型パラメーターを使ってもインターフェース型よりも速くならない (1.18 時点)
  • メソッドの実装が異なる場合は型パラメーターを使わない
  • 必要に応じてリフレクション (reflect パッケージ) を使う

​

func ReadSome(r io.Reader) ([]byte, error)

↓ ↓

func ReadSome[T io.Reader](r T) ([]byte, error)

セマンティクスはほぼ同じだが、�デメリットしかない (1.18 時点)

(注意) 1.20 で comparable� が事情を変えた!

22 of 26

まとめ

23 of 26

まとめ

  • 一般論としてジェネリクスは難しい
  • 一方で難しい (学習コスト高) というデメリットを上回るメリットがある
    • ジェネリクスがなくてもプログラミングできる => 正しい
    • プログラミングにジェネリクスはいらない => これはよく考えよう
  • 自分でもジェネリクスを使ったコードを書いて習得しましょう

24 of 26

リファレンス

25 of 26

リファレンス

​

26 of 26

雑談