LoginSignup
23
16

More than 1 year has passed since last update.

Go1.18でジェネリクスが追加されたので、5年ぶりにGolangを覚えなおし、GenericListとマルチキャスト可能Channelの実装をした

Posted at

はじめに

Go1.18が2022/3/15にリリースされました
以前Goの開発陣はジェネリクスは不要とか言ってたような気がするが
1.17でもお試しが出来たと思いますが
今回正式に導入されたので、5年ぶりにGoのお勉強です

ジェネリクス以外に5年間のUpdateを調べましたがツールやライブラリは変わったものの
言語じたいはたいした変化はなかったので
ジェネリクスだけです

ジェネリクスとは

C++でいうテンプレートです
D言語、Java、C#、Haskell・・・
今では様々な言語に導入されている概念です
引数の型が異なる関数やメソッド、メンバ、クラスに対して、一つの実装で複数の型に対応出来ます

具体的には、例えばint型の入るリストクラス IntListを作ったとします
float型のリストも必要となったのでコピペして FloatListクラスを作成
string型のリストも欲しいから StringListクラスを作成
自作したMyClassを保存する MyClassListを作成・・・
メンテできませんね。。

ジェネリクス例
List というクラスを作成し、メソッドを実装する
List hoge; と変数を定義した時に、int型のListクラスが生成される
同じく List List List ...と任意の型のListクラスを自動生成出来る

言語による実装の違い

  • C++、Rust等
    コンパイル時にそれぞれの型のコードを自動生成する
    型が明確に定まるため実行速度は最も速い
    コンパイル時間が長くなり、実行ファイルが大きくなる

  • Java等
    コンパイル時は型をチェックするが、ILレベルではObject型である(後方互換性)
    ListもListもILでは Listと同じため、実行ファイルはあまり増えない
    常にキャストが行われるため、実行速度は遅い
    また、全て同じObject型なので、リフレクションで型を取れない

  • C#等
    ILでGenericsに対応した命令セットが追加されている
    別の型として扱われるため、キャストも発生せずリフレクションでもちゃんと型が取れる
    ILでうまく吸収するため実行ファイルもあまり増えない

Goの実装がどうなっているか調査不足ですが、コンパイル言語なのでC++実装と近いと予想しています

おまけ、動的型付け言語

型によるコードの自動拡張なので、型がない言語では基本的にGenericsは使われない

静的型付け言語でも、ジェネリクスを使わず、JavaでいうObject型のような
何でも入る基底オブジェクトにすれば、ジェネリクスを使わずに全ての型に対応できる
ただし、型を制限できないので使い勝手は異なる

Goのジェネリクス

文法

文法は他の言語と似ているが、最も異なるのが多くの言語では <> で表記されるが、 [] で表記する事
配列と同じ記号なので、配列オブジェクトをテンプレート引数にしたりすると、読みにくい(なんで<>じゃないの?)

それ以外は他の言語とだいたい同じで、非常に素直
また、型制約も可能で、interfaceに使用可能な型を記述する
interfaceにはunion構文が追加され、型制約にかぎり複数の型のunionが使える
~をつかいunderlying typeも制約に追加出来る
制約に関して、言語ライブラリによく使うものがまとめられている(Ordered等)

など・・
詳細は他の人の記事をみるか、リクエストあれば作ります・・

type Number interface { 
    ~int | ~int32 | ~int64 | ~float32 | ~float64
}

func Add[T Number] (a, b T) T{
    return a+b
}


result := Add[int](1, 2)    // [int]で型を明示しているが、型推論されるので省略可能

そんなわけでジェネリックなListを作った

リポジトリは一番下においときます。。。

といっても、言語ライブラリの container/listをジェネリクスにしただけで
練習にもなってませんが
Goのジェネリクスが簡単だよ! 今までのコードを簡単にジェネリクス対応できるよ!
っていう事で

変更点
具体的には、ListとElementという structがあるので、それらをany制約(つまり制約なし)のジェネリクスにします

// これを
type Element struct {
type List struct {

// こうするだけ
type Element[T any] struct {
type List[T any] struct {

そして、ListやElement型を使っていたものをすべて List[T]、Element[T]に置換する

// こういうのを片っ端から
	next, prev *Element
	list *List
func (e *Element) Next() *Element {


// [T]つけるだけじゃ
	next, prev *Element[T]
	list *List[T]
func (e *Element[T]) Next() *Element[T] {

そして今回はコンテナの中身、ValueメンバもT型になります
またNewという公開関数も[T any]になる
その他 anyが出てくる部分はTに置き変えるべきな場合が多いのでそれらをすべて置き換える

// このへんのanyは
	Value any
func (l *List) insertValue(v any, at *Element) *Element {
func New() *List { return new(List[T]).Init() }

// Tになるんじゃ!
	Value T
func (l *List[T]) insertValue(v T, at *Element) *Element[T] {
func New[T any]() *List[T] { return new(List[T]).Init() }

これでcontainer/listはジェネリクス対応になりました

試す

	l := list.New[int]()
	l.PushBack(1)
	l2 := l.PushBack(2)
	l.PushBack(3)
	l.Remove(l2)

	for e := l.Front(); e != nil; e = e.Next() {
		println(e.Value)
	}

// 結果
1
3 

もちろんfloatやstringでも正しく機能する

マルチキャストチャンネルの作成

上記のジェネリクス対応Listを使って何か遊ぼうと思って、いいものが思いつかなかったので
以前からGoで不満を持っていた、複数チャンネルへのマルチキャストを実装しようと思う

Goroutineは基本的にはchannelによるメッセージパッシング方式を好む
もちろんグローバルでフラグをもち共有メモリ・・も実装可能であるが
channelを使う方が良いだろう

例えば、複数のコネクションにたいしGoroutineを作成し、親の終了時に子を終了させるなら
終了用チャンネルを作り、Closeして子がそれをselectで受ければ実装できるが
チャンネルをCloseしたくない場合
例えば複数人とのチャットサーバを作り、すべてのコネクションにメッセージを送る場合等
1つのchannelにメッセージを送ると、ブロードキャストしてくれるようなもの
欲しい時がある

goでは知る限り、上記を満たすものがないため作った

モチベーション

こちらの実装がたいへん参考になったが、少し不満点があった
上記の実装は非常にシンプルで良い実装だが
シンプルなリンクリストを作成し、親チャンネルからメッセージが来たら
リンクリストをたどり、結果登録した全てのListenerに対して親からきたメッセージを送信している

しかし不満点および疑問点があった

不満点は、任意のListenerを削除できない点
双方向リンクリストにすれば削除も可能と思われるが、それならListを内包する方が良くないか?と思った

また疑問点は、ListenerごとにGoroutineを作っているが
このGoroutineは
メッセージを受け取った場合 リンクリストがあれば、次の子にメッセージを伝播し、自分のチャンネルにメッセージを投げる
以下、リンクリストをたどり、上記Goroutineが走る
つまり、親がメッセージを受け取った場合
第一子にメッセージを投げる、第一子は第二子にメッセージを投げ、自分のメッセージを処理する
・・・
と、子の数をnとすると、 2n+1個のチャンネルを作り、それらに伝播し、最終的にn個のチャンネルに伝播される
無駄が多くね???

あとは、チャンネルに送るメッセージがジェネリクスではないので、すべてinterfacfe{}である。
これをジェネリクスにして型安全にしたい(もちろん anyにする事もできる)

設計方針

上記のジェネリックのListを使い、Listenerを追加削除が自由に行えるようにする
また、子への伝播用のchannelを生成せず、親がListを使い全ての子にたいしてメッセージを送るようにする
つまりchannelの伝播を 2n+1からn+1に減らし、Goroutineもnから1に減らす

型の定義

type Listener[T any] struct {
	C chan T
	E *list.Element[*Listener[T]]
}

func (s *Listener[T]) Close() {
	close(s.C)
}

type Channel[T any] struct {
	C chan<- T
	c chan T
	l *list.List[*Listener[T]]
	m sync.Mutex
}

リンクリストは不要。かわりに l *list.List[*Listener[T]] と、Listenerのリストを保持する。
ただしGo言語ではデストラクタが存在しないため・・・
このListの中身を自由にRemoveされると、Closeされず困った事になる・・・辛い・・・

ので現在は、ChannelのPublicメソッドとしてAddとRemoveを用意している・・・辛い・・・

C chan<- T
c chan T

ここは、参考の実装からそのままいただいた。非常に素晴らしい実装だと思う
Cとcのメンバ変数は、後のNewで説明するが、同じチャンネルであるが
公開用のCは、入力専用であるが、内部メンバのcは子に伝播させるように出力用になっている
非常にスマートだ!

そしてNew


func New[T any]() *Channel[T] {
	c := make(chan T)
	l := list.New[*Listener[T]]()

	go func() {
		for v := range c {
			if l != nil {
				for e := l.Front(); e != nil; e = e.Next() {
					e.Value.C <- v
				}
			}
		}

		if l != nil {
			for e := l.Front(); e != nil; e = e.Next() {
				e.Value.Close()
			}
		}
	}()

	return &Channel[T]{
		C: c,
		c: c,
		l: l,
	}
}

ほぼすべてを物語っている
先ほど話したように、Cとcには同じチャンネル(ブロードキャストするためのインタフェース)を指定しているが
送信用、受信用と使い分けている。すばらしい

そしてListenerを保存するためのListを作成
受信をし、リストから子に伝播させるためのGoroutineを起動

後始末として、ListenerのチャンネルをCloseしている(デストラクターがない・・・辛い・・・

その他のメソッド


func (c *Channel[T]) Add() *Listener[T] {
	c.m.Lock()
	defer c.m.Unlock()

	l := &Listener[T]{C: make(chan T, 0)}
	e := c.l.PushBack(l)
	l.E = e

	return l
}

func (c *Channel[T]) Remove(l *Listener[T]) {
	c.m.Lock()
	defer c.m.Unlock()

	c.l.Remove(l.E)
	l.Close()
}

func (c *Channel[T]) Close() {
	c.m.Lock()
	defer c.m.Unlock()

	close(c.c)
}

ListenerをAddしたりRemoveしたりするその他メソッドだ
コンストラクタやデストラクタさえあれば、Listをそのまま出せるかもしれないが
Goにはコンストラクタもデストラクタも存在しないので・・・辛い・・・
こうやって、インタフェースを別途作らないといけないのか・・

もしかしたらListクラスをダックタイピングで派生させれば出来るのかもしれない
そのあたりは、私のGoの知見の低さゆえ・・今はこの実装で勘弁してください

使い方

実際にはWaitGroupを使ったり、selectで終了をまったり、必要な作業はありますが
大雑把に下記のイメージで


	c := multichannel.New[interface{}]()

	for i := 0; i < 3; i++ {
		go func(c *multichannel.Channel[interface{}]) {
            c.Add()
            ....
        }(c)
	}

	c.C <- "1"
	time.Sleep(time.Second)

	c.C <- "2"
	c.Remove(ll)
	time.Sleep(time.Second)

	c.C <- "3"
	time.Sleep(time.Second)
	c.C <- "4"

	c.Close()

このへんは、詳しくは 下記のmain.goを見て下さい(リファクタリングしてないけど)

むしろWorkingGroupの使い方の方がややこしい(こっちもライブラリ作ろうかなあ・・・)

参考コードのリポジトリ

モジュール化して、go getで使おうとしたけど上手くモジュールとして動かない・・
githubでのモジュール化について勉強しなきゃ・・

23
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
16