💬

Go言語が好きな理由

2021/09/22に公開
8

はじめに

私はGoが好きなので、disられている場面に遭遇すると心が痛みます。残念ながらプログラミング言語について深く語れるほどの知識や経験は持ち合わせていないため、世界が平和になることを祈るくらいしかできません。

それはそれとして、Goが好きな理由を語る人はあまり見かけない気がします。この記事ではGoが好きな理由を視覚に障害のあるユーザーの視点から語ります。読み終えたところで得るものは何もありませんし、長いので覚悟して読んでください。

あなたは誰?

4年ほど業務でサーバーサイドのGoを書いています。また、業務で使いはじめる前から趣味でGoに触れていました。そのため無意識の内にひいきしているかもしれません。ただし、流行っているからといって理由もなくGoを勧めたりはしません。

視覚障害ならではのコーディング事情

Goが好きな理由と深く関わるので、プログラミング言語の話をする前に視覚障害ならではのコーディング事情について説明します。

突然ですが、青・白・赤の3色で構成された長方形が視界に入った場面を想像してください。おそらく、一瞬でフランス国旗であると認識できるはずです。

しかし、3人が一斉に「青」「白」「赤」と叫んだら聞き取れるでしょうか?それがフランス国旗であると確実に伝えるには1人ずつ順番に「青」「白」「赤」と叫ぶ必要があります。

そもそも、いきなり「青」「白」「赤」と叫ばれても意味不明です。「フランス国旗の色を説明するぞ〜左から順番に〜」と誰かが叫ばなくては何の話かわかりません。

視覚から得られる情報は膨大です。シンタックスのハイライトやツールチップの警告表示が視界に入った瞬間にコードの不備が判別できます。一方、聴覚から得られる情報は限定的です。

私の周囲ではVisualStudio Codeを利用している視覚障害の方が多数を占めています。(私はVim派です)しかし、どれだけ高機能なエディタを使ったとしても一度に聞き取れる読み上げ内容には限りがあります。また、読み上げのスピードを最速に設定しても聞き取るのに数秒の時間は必要です。

余談になりますが、聴覚を利用したコーディングを擬似体験する簡単な方法があります。1行だけ見えるようにエディタを他のウィンドウで隠すのです。語弊を恐れずに言えば、それが視覚障害の世界です。

プログラミング言語に求めるもの

私にとって扱いやすいプログラミング言語とは、一度の読み上げで多くの情報を聞き取れる言語です。現状、その点でGoは私にとって扱いやすいプログラミング言語の一つです。これは後で詳しく説明します。

読み上げしやすさの他に、あえて気に入っているところを挙げるなら、標準パッケージの豊富さとツールチェーンが充実している点です。並行処理が簡単に書けるとか、ハイパフォーマンスだとか、そのようなGoの特徴は私にとってそれほど重要ではありません。

聞き取りづらいコードの例

Goとの対比として最適なので、まずはRubyのコードをご覧ください。以下は高速フーリエ変換を行うコードです。

def fft(a)
  n = a.size
  return a if n == 1
  w = Complex.polar(1, -2 * Math::PI / n)
  a1 = fft((0 .. n / 2 - 1).map {|i| a[i] + a[i + n / 2] })
  a2 = fft((0 .. n / 2 - 1).map {|i| (a[i] - a[i + n / 2]) * (w ** i) })
  a1.zip(a2).flatten
end

Ruby on Railsのコードを例に説明できると良いのですが、最後に触ったのは5年ほど前なので最近のRails事情を全く把握できていません。そこで、サーバーサイドとは何の関係もないロジックを例に説明します。

(追記)上記のコードは数式をなるべくそのまま表現したコードになっています。Rubyで実装したからといって必ずしもコードが複雑になるわけではありません。

上記のコードは以下の記事から引用しました。インデントを含め、一切の改変なくコードを貼り付けたはずです。もし不備があれば指摘していただけると助かります。

なぜ読みづらいのか

それでは、私にとってRubyのコードがなぜ聞き取りづらいのか説明します。コードを聞き取るというのは日本語として違和感があるため、以降はコードを読み取るという文言に統一します。

なお、RubyとGoを比較した場合、私にとって相対的にRubyのコードが読みづらいだけであり、プログラミング言語としてRubyが劣っていると主張する意図はありません。また、Goのコードが読みやすいからといってGoが優れたプログラミング言語であると主張する意図もありません。

1行目

def fft(a)

Rubyの場合、グローバルなスコープに定義されたメソッド、つまり関数と通常のメソッドはどちらもdefキーワードから始まります。それがメソッドであるかを確認するには前の行へ読み上げのカーソルを移動して、classmoduleといったキーワードが出現するか聞き取らなくてはなりません。あるいはIDEの支援機能に頼ることになります。

それに対してGoの場合、func doSomethingという1行が聞こえたら関数定義であると確定します。メソッドについてはfunc (r Receiver) doSomethingという1行が聞こえたらメソッド定義であると確定します。

数十行のRubyコードを読み進めるのは苦労しません。しかし、数百行あるコードの中の特定の行から読みはじめる場合、メソッド名が重複している可能性を考慮する必要があります。例えば、Foo#helloの実装へカーソルを移動したはずが、間違えてBar#helloの実装に移動しているかもしれません。このときdef helloと書かれた行を読み上げただけでは、それが何のメソッドなのか判断できません。

疑念を確実に解消するにはdefキーワードが出現した行より前の行へカーソルを移動してメソッドが定義されているスコープを確認するか、IDEの支援機能に頼る必要があります。その手間はたいした手間ではないかもしれません。しかし、数秒の操作も積み重なれば数十分あるいは数時間を費やすことになります。

Goでメソッドを定義する場合、func (f Foo) hello()func (b Bar) hello()と書きます。丸かっこの中にレシーバーを指定する必要があるため、今この瞬間、聞こえた内容から読み上げ中のカーソルがFoohelloメソッド、あるいはBarhelloメソッドの定義に位置していることが確定します。疑念が生まれる余地はありません。

2行目

n = a.size

Rubyはメソッド呼び出しの丸かっこを省略できます。この行を読み上げただけではそれがメソッドなのかプロパティなのか判断できません。sizeのように一般的なメソッドを聞き間違えることは少ないと思いますが、これもIDEの支援機能がなければ、その場でメソッドの詳細を調べるのは苦労します。

Goの場合、メソッド呼び出しは常に丸かっこが必要です。また、末尾に丸かっこがつかないa.bのようなシンタックスはパッケージの名前.変数の名前または構造体の名前.フィールドの名前の2択に絞られます。疑念が生まれる余地はありません。

Goのプリミティブ型にはメソッドが定義されていませんし、プリミティブ型にメソッドを後から定義することもできません。そのためスライスの要素数を取得するにはlen(a)と書く必要があります。もし、lenを使わずa.Size()と書かれていれば、aの型はプリミティブ型ではないと推測できます。

Goにはpublicprivateといった可視性を制御するキーワードが存在しません。その代わり大文字から始まるメソッドはパブリック、小文字から始まるメソッドはプライベートと決められています。そして、音声エンジンの種類にもよりますが、大文字と小文字は読み上げる声のピッチで判別できます。高い声は大文字、低い声は小文字です。もしa.Size()ではなくa.size()と書かれていたらプライベートなメソッドであると即座に判断できます。

さらに、プライベートなメソッドの定義の場合、読み上げている最中のファイル、あるいは同じディレクトリ内の.goファイルにメソッドの定義があると確定します。ここではメソッドを例に説明しましたが、関数の定義についても同様です。そのためIDEの支援機能がなくてもメソッド定義へたどり着くのは苦労しません。

ある1行のコードを読み上げたとき、その行から推測あるいは確定できる情報の多さがGoの特徴です。言い換えると、そのような特徴を備えた言語であれば、私にとって都合が良いと言えます。

3行目

return a if n == 1

Rubyのシンタックスとして後置ifは許可されています。そのため自然言語に近いコードを書くことが可能です。ただし、「リターン」と聞こえた時点で関数定義がここで終わるのかと一瞬の混乱が生じます。最後まで聞くと後置ifがあることに気づき、この1行が条件分岐であると確定します。

Rubyはdef-endブロック内の最後の行が戻り値として評価されます。しかし、本来returnが必要ない箇所にreturnが書かれていることがあります。

def hello
  return "Hello, World!"
end

例えば、上記のreturnは不要です。return "Hello, World!"ではなく、"Hello, World!"と書くべきです。これくらい単純な処理であればすぐに気づきますが、複雑な処理を読み上げている途中であれば聞き逃すかもしれません。

lintツールを導入して表記揺れを解消することはできます。しかし、同じ目的を実現する手段が複数ある状態を根本的に解消することはできません。Rubyの場合、その疑念が解消できないため「リターン」の「リ」が聞こえた時点で、条件分岐の可能性と関数を抜ける可能性の2パターンを想定して、その後の読み上げの内容に耳をすます必要があります。

余談になりますが、Goも不要なreturn文を書くことができます。

func hello() {
	fmt.Println("Hello, World!")
	return
}

上記のreturn文は不要です。しかし、Goには後置ifは存在しないため「リターン」の「リ」が聞こえた時点で、それがreturn文であると確定します。疑念が生まれる余地はありません。

4行目

w = Complex.polar(1, -2 * Math::PI / n)

この行は聞き取りやすくて助かります。メソッド呼び出しの丸かっこが省略されていないため、何をする処理なのか一目(一聴?)瞭然です。この行については、Goで実装してもほぼ同じシンタックスになります。

余談になりますが、仮にComplex.polarのようなGoコードに遭遇しても型が明らかなのでドキュメントを調べるのは簡単です。ターミナルを開いてgo doc 型.メソッド名と入力するだけです。あるいはgo doc パッケージ名.型.メソッド名と入力するだけです。

Rubyはダックタイピングが基本ですから、型を気にしません。メソッドの呼び出しが成功すれば処理は続行します。それはそれで便利なのですが、オブジェクトがスコープ内でどのような振る舞いを期待されているか把握する必要があります。

私は視覚に障害があります。残念ながら一度に多くの行を見渡すことはできません。あるオブジェクトに求められている振る舞いを知るにはdef-endブロック内の処理を一通り読む必要があるため、読み上げのカーソルを上へ下へ移動させて全体像を把握する必要に迫られます。その手戻りが数行なら問題ありませんが、数十行あるいは数百行になると一苦労です。

5行目・6行目・7行目

a1 = fft((0 .. n / 2 - 1).map {|i| a[i] + a[i + n / 2] })
a2 = fft((0 .. n / 2 - 1).map {|i| (a[i] - a[i + n / 2]) * (w ** i) })
a1.zip(a2).flatten

上記の3行にはRubyらしいシンタックスが凝縮されています。レンジを意味する..リテラル、関数の呼び出しに続けて呼び出されているmapメソッド、Goにそのようなシンタックスは存在しません。

私の場合、コードゴルフ的な賢い1行よりも愚直な10行を聞き取るほうが好みです。賢い1行は少ない文字で多くの処理が記述されています。1文字たりとも聞き逃さないよう慎重に読み進める必要があります。一方、愚直な10行は気楽に読み進めることができます。

抽象的な話になりますが、私の場合、集中力1ポイントで1行を聞くのと、集中力0.1ポイントで10行を聞くのは労力として同じです。それならば、最初から10行に分解された愚直なコードを読み進めるほうが手間が省けて楽です。

大事なことなので何度でも繰り返しますが、Rubyのシンタックスが良い悪いと主張する意図は一切ありません。機能が絞られているGoのシンタックスは私にとって都合がよい、それだけです。

8行目

end

Rubyコードにおけるオアシス、それはendのみ書かれた行です。この行に到達すると緊張の糸がほぐれるのを感じます。しかし、do ... end.map ...のようにendに続けてメソッドチェーンが出現する可能性があるため、油断はできません。

ところで、波かっこを使って記述されたブロックとdo-endで記述されたブロック、どちらが好みかというと波かっこです。endが確実にendであると確認するには1文字ずつ「イー・エヌ・ディー」と読み上げして確認する必要があるからです。一方、波かっこであれば、その1文字は「波かっこ」と読み上げされます。疑念が生まれる余地はありません。

愚かな言語?

Goにはmap-reduce的な機能が存在しません。forループを書く必要に迫られます。エラー処理がお粗末との指摘もあります。これらについて少し掘り下げてみます。この節も前の節と同様に、あくまで私にとってコードを読みやすいかを軸に説明します。

userテーブルの定義

あなたが開発しているWebサービスではRDBでユーザーを管理しているとします。以下のテーブルを例に説明を進めます。

CREATE TABLE user (
  id INT NOT NULL,

  -- activeがtrueの場合はサービスを利用中、falseは休眠状態を意味する。
  active BOOL NOT NULL,

  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  deleted_at DATETIME,

  PRIMARY KEY (id)
);

休眠状態のユーザーを論理削除する例

以下は休眠状態のユーザーを論理削除するGoのコードです。データベースの操作にはsqlboilerを利用しています。生成したモデルはserver/modelsに配置されている想定です。

package foo

import (
	"context"
	"server/models"
	"time"

	"github.com/pkg/errors"
	"github.com/volatiletech/null"
	"github.com/volatiletech/sqlboiler/boil"
)

func deleteInactiveUsers(ctx context.Context, tx boil.ContextTransactor) error {
	users, err := models.Users(
		models.UserWhere.Active.EQ(false),
		models.UserWhere.DeletedAt.IsNull(),
	).All(ctx, tx)

	if err != nil {
		return errors.Wrapf(err, "database error")
	}

	now := time.Now().UTC()

	for _, user := range users {
		user.DeletedAt = null.TimeFrom(now)

		if _, err := user.Update(ctx, tx, boil.Infer()); err != nil {
			return errors.Wrapf(err, "database error")
		}
	}

	return nil
}

スライスの操作にforループを使うのが苦ではない理由

上記のGoコードを読んで最初に気付くのはforループ内でUPDATE文が実行されている点です。この実装は非効率です。ユーザーの数が増加するとUPDATE文の発行回数が線形に増加します。sqlboilerで生成されたコードにはUpdateAllメソッドが定義されているため、それを使ってSQLの発行回数を1回に削減するべきです。

ところで、仮にGoの言語機能としてmap-reduce的なメソッドチェーンがサポートされていたとしてもイテレーションの中で何度もSQLが発行されるのを防ぐことはできません。その機能を使うことで実装に何らかの制限を加えることができるなら使うべきです。しかし、同じ目的を実現する手段が増えるだけなら私は歓迎しません。機能が絞られていればコードを聞き取るときに余計な推測をしなくて済むからです。

それに加えて、私にはforループを使う動機があります。コードに変更を加えたときの差分が聞き取りやすい、という点です。この特徴はコードのレビューを受ける、あるいはレビューをする際にとても役立ちます。

メソッドチェーンを駆使して書かれた1行の場合、差分を探すのに苦労します。git diffで差分を表示すると変更前後の行がそれぞれ1行表示されるだけです。そのため、差分を探すには単語単位あるいは文字単位で読み上げのカーソルを移動しつつ慎重に聞き取りする必要に迫られます。

forループは処理を1行ずつ書く必要に迫られます。実装している最中は面倒です。しかし、1行ずつ処理を書かなくてはならない欠点は、git diffで最小限の差分のみ表示される利点と表裏一体です。私の場合、利点が欠点を上回っているためforループを使うのが苦ではありません。

エラーハンドリング

上記のGoコードはエラーハンドリングしている箇所が2つあります。ただerrを返すだけでは不十分なのでerrors.Wrapfでコールスタックの情報を追加しています。また、サードパーティーのgithub.com/pkg/errorsパッケージはerrorインターフェースを実装しています。関数を跨いでエラーを捕まえたければGo標準のerrorsパッケージに実装されているerrors.Iserrors.Asを利用できます。

その2箇所で発生する可能性のあるエラーはデータベースのコネクション切断やトランザクションのタイムアウトなどです。そのようなエラーが発生した場合はアプリケーション側で対処できないため、エラーを返して処理を抜けるしかありません。しかし、2つめのエラーハンドリングをよく読むと_, errと書かれています。左側のアンダースコア(_)は変数への代入を破棄していることを意味します。なぜそのような実装をしたのか、おそらくコードレビューで指摘されるはずです。

try-catch的な例外を処理する言語機能がGoに必要なのか、私にはわかりません。機能としてエラーを処理する手厚い支援があったとしても、適切なエラーハンドリングが行われるかどうかは実装する人に左右されるからです。

また、例外を利用して実現したい処理とずれているかもしれませんが、Goにはpanic-recoverがあります。あれもほしいこれもほしいと闇雲に機能を増やされても余計な推測をしながら読み上げた内容を聞き取る必要に迫られるため、機能が豊富というのは手放しで歓迎できません。

コードの記述量が多いためメンテナンス性が上がる

テーブル定義を変更することになり、activeカラムをfrozenカラムにリネームすることになったとします。リネームに伴いfrozenカラムに保存される値の意味も変化します。以前のカラムのtrueとfalseの意味が逆転し、trueの場合に休眠状態となります。

sqlboilerでコードを再生成するとmodels.UserWhere.Activeは参照エラーになり、ビルドは失敗するようになります。このときビルドエラーのメッセージから修正の必要な箇所が炙り出されます。

言語によってはメタプログラミングを駆使して一切の変更なくカラムのリネームに対応できるかもしれません。それはそれで便利なのですが、増改築を繰り返している複雑なシステムに手を加える場合は変更箇所の洗い出しをするのに苦労します。ビルドが失敗したときのメッセージは作業を進める上で重宝します。

JSONを扱うのに構造体が必要になる

目が見えていればインデントを頼りに階層構造が把握できるかもしれません。しかし、私は視覚に障害があります。スキーマのわからないJSONを読み進めるのは苦労します。オブジェクトの階層構造を記憶にとどめつつ読み上げた内容を聞き取る必要があるからです。

interface{}を使って手を抜くことも可能ですが、GoでJSONをパースするには事前に構造体を用意するのが基本です。そして、その構造体の定義はスキーマとして機能します。文字どおり構造体の定義はJSONがどのような構造なのかを定義します。その構造を事前に把握することで、例えばログに出力されたJSONを読み進めるときの負担が激減します。

レスポンスとしてJSONを返すAPIを呼び出す場合も、この縛りが役に立ちます。同じAPIを呼び出していて、なおかつ自身のコードに一切変更がないのにパースが失敗するならば、それは呼び出しているAPIに不備があると確定します。私は自分のコードに不備があるはずだと常に疑っています。そのため、予期しない状況に遭遇した場合は即座にエラーを返してくれるほうが助かります。

ただし、構造体を定義するのが面倒で融通が効かない点については不満があります。

// Code generated by SQLBoiler (https://github.com/volatiletech/sqlboiler). DO NOT EDIT.
// This file is meant to be re-generated in place and/or deleted at any time.

package models

// 省略

// User is an object representing the database table.
type User struct {
	ID        int       `boil:"id" json:"id" toml:"id" yaml:"id"`
	Active    bool      `boil:"active" json:"active" toml:"active" yaml:"active"`
	CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"`
	UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"`
	DeletedAt null.Time `boil:"deleted_at" json:"deleted_at,omitempty" toml:"deleted_at" yaml:"deleted_at,omitempty"`

	R *userR `boil:"-" json:"-" toml:"-" yaml:"-"`
	L userL  `boil:"-" json:"-" toml:"-" yaml:"-"`
}

上記のように、sqlboilerで生成された型定義にはJSONへシリアライズするためのタグが埋め込まれています。タグがなければ{"ID":12345,"Active":true, ... }"のようにフィールド名がそのままキーとしてシリアライズされます。

問題はフィールドのタグを1箇所だけ変更したい、といった微調整が必要な場合に発生します。1箇所だけであれば手作業で修正するのは苦労しません。しかし、コードを再生成すると再び手作業で修正する必要が生じます。

ビルド用のシェルスクリプトを用意して、sedで書き換えるのは一つの手段です。あるいはsqlboilerのコード生成を行うテンプレートを自前で用意する方法もあります。しかし、前者は意図しないパターンに引っかかって生成されたコードが壊れる恐れがあります。後者は修正する手段として大袈裟すぎる気がします。リフレクションで実行時にタグを書き換える手段もありますが、やはり大袈裟すぎる気がします。

nilの扱い

sqlboilerではnullableな値を型として定義しています。null.TimeFrom(now)null.Time型の値を返します。Goがオプショナルな値をサポートしていれば、このような回りくどい実装は不要だったかもしれません。

ただし、それが愚かだとは思いません。どうあがいても目的が達成できないなら機能不足であると非難されても仕方ありません。しかし、現状の言語機能でnullableへの値のマッピングは達成できています。読み上げしやすさの観点からも私にとって不都合はないので、これが深刻な問題とは考えていません。

話は脱線しますが、私はWindowsのCOMインターフェースとして提供されているWASAPI(音声関連のAPI)のFFIをGoで書いたことがあります。その実装はC拡張を一切使わず、ピュアGoで実装できました。

Goがどのような哲学で設計された言語なのか詳しいことはわかりません。しかし、低いレイヤーのコードをGoだけで実装できたことから察するに、nilの扱いに無頓着ということはないはずです。安全性よりも利便性を重視するとか、何らかの判断の末に今の形に落ち着いたのだと思います。

Goの不満点

ここまでの内容を振り返ると、私はまるでGo信者です。しかし勘違いしないでください。もちろんGoは好きですが、Goだけが好きとは一言も書いていません。

そこで、いくつかGoの不満点を挙げてみます。Go言語のマスコットであるGopher君が描かれた板をつま先で優しく踏み踏みしている姿を想像しながら読み進めてください。

パッケージ名の省略ができる

まずはパッケージのインポートについての不満です。パッケージをインポートするとき. "fmt"と記述すると、fmt.Printlnfmtが省略できます。この機能は私にとってお節介な機能です。Printlnとだけ書かれていると、それが外部のパッケージからインポートした識別子なのか同一パッケージ内に定義されている識別子なのか判断できません。

回避策としては、import文の.を削除してからビルドする方法があります。そうするとビルドは失敗します。このときエラーメッセージから未定義の識別子が炙り出されるため、パッケージ名が省略されていた箇所が判明します。

とはいえ、その回避策を実行するのは面倒です。パッケージ名は省略できない縛りがあるほうが、私は助かります。

init関数は複数定義できる

以下のGoコードをご覧ください。

package main

import (
	"fmt"
)

func init() {
	fmt.Println("A")
}

func init() {
	fmt.Println("B")
}

func init() {
	fmt.Println("C")
}

func main() {
	fmt.Println("Done!")
}

上記は合法なGoコードです。実行すると「A B C Done!」と1行ずつ表示されます。

続けて以下のGoコードをご覧ください。

package main

import (
	"fmt"
)

func initialize() {
	fmt.Println("A")
}

func initialize() {
	fmt.Println("B")
}

func initialize() {
	fmt.Println("C")
}

func main() {
	initialize()
	initialize()
	initialize()
	fmt.Println("Done!")
}

上記は違法なGoコードです。実行すると以下のエラーメッセージが表示されます。

$ go run main.go
# command-line-arguments
./main.go:11:6: initialize redeclared in this block
	previous declaration at ./main.go:7:6
./main.go:15:6: initialize redeclared in this block
	previous declaration at ./main.go:11:6

読み上げのしやすさとは関係ありませんが、init関数の扱いは罠にはまりがちです。GoはCみたいな言語なんだろうと調べもせず適当な実装をすると想定外の言語仕様に混乱するはずです。

私の場合、読み上げをするときはinit関数の出現に最大限の警戒をします。特に行数の多いGoコードを読み進めるときは最初にinit関数が定義されていないか調べることから始めます。

同一ディレクトリに異なるパッケージを配置できる

例えば、あるディレクトリにfoo.gofoo_test.goが配置されているとします。

$ ls
foo.go        foo_test.go

foo.goの内容は次のとおりです。

package foo

// 省略

foo_test.goの内容は次のとおりです。

package foo_test

// 省略

Goはファイル名に_testサフィックスがある場合、そのファイルをテストコードとして扱います。通常はテスト対象のパッケージと同一のパッケージで実装しますが、テストコードについては_testというサフィックスのついた異なるパッケージで実装することが可能です。この機能はパッケージの循環参照を回避する方法として役に立つ場合があります。

goのコードリーディングをする場合、私は最初にパッケージ名を聞き取ります。読み上げしやすさの観点から評価するなら、この機能はお節介です。パッケージ名が異なると、開くファイルを間違えたのかと一瞬の混乱が生じるからです。とはいえ、この機能が必要になる場面があるのは確かなので、強い不満はありません。

その他

Goコードの読み上げに関連する不満と言えば上記の3つくらいです。その他にも小さな不満、例えばgo docで表示されるコメントは特定の文字数で改行が挟まれるため読みづらいのですが、それは些細な問題です。

Go v1.18で導入予定のジェネリクスについて

Goにジェネリクスがほしい、という意見を見かけることがあります。不満についての話から脱線しますが、ジェネリクスに対する私の意見を残しておきます。

まず、ジェネリクスを導入することについて特に意見はありません。私はGoのコアコミッターではありません。Go言語かくあるべし、という哲学あるいは強いこだわりもありません。導入されたら使うかもしれませんが、ジェネリクスのない現行バージョンが不便だとは感じていません。

それよりも気にしているのはGoのシンタックスが大きく変更されるかどうか、という点です。現状、ジェネリクスの導入前後で極端なシンタックスの変化はありません。読み上げにも支障はなさそうです。そのため導入については賛成も反対もしません。

例えば、Goのsortパッケージには各プリミティブ型に対応するsort.Intssort.Stringsが定義されています。野暮ったいなとは思いますが、それが実行時のパフォーマンスに致命的な影響を与えているわけではありません。任意の型をソートしたければsort.Sliceがありますし、現行バージョンにジェネリクスが存在しないことが深刻な問題とは考えていません。Goにジェネリクスが導入されたら野暮ったいコードが小綺麗なコードに置き換わるんだろうな、程度の認識です。

マジで勘弁してほしい言語機能トップ3

唐突ですがマジで勘弁してほしい言語機能のトップ3を発表します。以降はGoの話を忘れてください。ただの愚痴です。

(第1位)演算子オーバーロード

まずは第1位の発表です。以下のC++コードをご覧ください。

std::cout << "Hello, World!\n";

大事なことなので先に断っておきます。決してC++を貶める意図はありません。私にとって読みづらいコードの一例として、C++を選んだだけです。C++が好きな皆様、ごめんなさい。

マジで勘弁してほしい言語機能の、第1位に輝いたのは演算子オーバーロードです。もう本当に勘弁してください。理由は言わずもがな、本来の演算子が持つ意味が破壊されるからです。

どこの誰が書いたのかわからないコードを読み進めるとき、演算子オーバーロードは牙を剥きます。独自に定義した型に対して、本来の意味からは想像もできないような演算子を割り当てているかもしれません。その疑念を抱えたままコードを読み進めるのは大変です。

なお、Go言語に演算子オーバーロードは存在しません。穏やかな心でコードを読み進めることができます。

t1 := time.Now()
d1 := 30 * time.Minute
t2 := t1.Add(d1)

上記は現在の時刻をt1に代入して、その30分後の時刻をt2に代入するGoのコードです。Addメソッドの呼び出しが野暮ったいと感じたでしょうか?もしGoが演算子オーバーロードをサポートしていたら、t2 := t1 + d1と書けたはずです。

上記のGoコードであれば演算子オーバーロードは役に立つかもしれません。しかし、それは演算子オーバーロードを扱う人の善意に依存しています。人々を混乱させることに快感を覚えるスーパーハカーが+演算子と-演算子をひっくり返すのを防ぐことはできません。そして、そのコードを読んだ私は泡を吹いて倒れていたはずです。t2 := t1 + d1の1行を読み上げただけでは+演算子の意図がわからないからです。

(第2位)アトリビュート

続いて第2位の発表です。以下のSwiftコードをご覧ください。

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var app

    // 省略

}

大事なことなので先に断っておきます。決してSwiftを貶める意図はありません。私にとって読みづらいコードの一例として、Swiftを選んだだけです。Swiftが好きな皆様、ごめんなさい。

第2位に輝いたのはアトリビュートです。こちらも演算子オーバーロードと同様に本来のシンタックスに別の意味を追加できてしまう厄介者です。

とはいえ、演算子オーバーロードの破壊力が鼻血が出る程度の顔面パンチとするならアトリビュートの破壊力はデコピン程度です。Swiftのアトリビュートは必ず@から始まります。アットマークが聞こえたら警戒体制に入る準備ができます。演算子オーバーロードのような不意打ちを喰らう心配はありません。

また、Swiftの場合、お決まりのアトリビュートがいくつか存在します。例えばSwiftUIであれば@Environment@Publishedは頻繁に出現します。アトリビュートが出現しそうな箇所は推測できます。聞き慣れるとpublicprivateといったキーワードと同じ感覚で聞き流すことができます。

(第3位)マクロ

最後に第3位の発表です。以下のC++コードをご覧ください。

#include <iostream>

// 省略

int main() {
  SELECT COUNT FROM TABLE;

  std::cout << "Done!" << std::endl;

  return 0;
}

大事なことなので先に断っておきます。決してC++を貶める意図はありません。私にとって読みづらいコードの一例として、C++を選んだだけです。C++が好きな皆様、二度目になりますが、ごめんなさい。

さて、main関数の中に不穏な1行が混ざっています。一見するとSQLのようですが、これは何でしょうか?省略されていた部分を展開しましょう。

#include <iostream>

#define SELECT "    "
#define COUNT "    "
#define FROM "    "
#define TABLE "    "

int main() {
  SELECT COUNT FROM TABLE;

  std::cout << "Done!" << std::endl;

  return 0;
}

出ました、第3位の登場です。言語を捻じ曲げる大いなる力、マクロです。その気になれば言語の中にもう一つの言語を構築できるため、破壊力は抜群です。第2位と第3位を入れ替えるべきか迷いましたが、上記のような虚無マクロは滅多に遭遇しないはずですから第3位としました。

上記のC++コードはプリプロセッサによってSELECT COUNT FROM TABLE" " " " " " " "という文字列へと変換されます。そして、C++のシンタックスに従い空白を含んだ複数の文字列は単一の文字列へと連結されます。その文字列は式として評価されますが、特に何をするわけではありません。虚無です。

なお、ある言語の中に別の言語が書かれていると困る、という話ではありません。例えばSQL文字列がコードの中に埋め込まれていても読み進めるのには苦労しません。その他、例えばReactのJSXも読みづらいとは感じません。混乱が生じるのは、それがまるで言語組み込みのキーワードのように読み取れる書き方がされている場合です。

余談になりますが、C++の場合、特別な理由がない限り#defineディレクティブではなくconstconstexprキーワードによる定数定義を利用してください。マクロは単純な置換です。スコープの概念がありません。意図せずマクロで設定されているのと同じ文字列を使ってしまうと、エラーが発生したとき何が起きているのか把握するのに苦労します。

おわりに

「そうなんです、私もGoを使いはじめてから2年ほどは不自由極まりない言語だと感じていました。現職でGoを本格的に使う機会がなければ、件の記事と同じ印象を持ったままGoから離れていたと思います。Goはいいぞ〜」

こう書くとヤバい宗教団体を解体してやるぜ☆と飛び込んだ若者が洗脳されて戻ってきた的なうさん臭さが漂うので難しいところですが、私のことは嫌いになってもGoのことは嫌いにならないでください。

Discussion

mattnmattn

読み上げという興味深い視点の記事、ありがとうございます。
1点、誤字の修正です。中段少し手前「機能が絞られていればコードを聞き取るときに余計な推測をしなくて住むからです」の「住む」は「済む」の誤字だと思います。

Yusuke EndohYusuke Endoh

元ネタのRubyコードを書いた者です! 同じく、とても興味深くよませていただきました。

Rubyコードの拾い読みは確かに難しいですよね。どのクラスの定義なのか、privateメソッドかpublicメソッドか、視覚障害がなくてもわからなくなりがちです。

ところで、私のコードについて、1つだけ言い訳させてください。このコードは、FFTの数式をなるべくそのまま表現したものになっています。一般的に、Rubyのコードの密度が他言語より高めなのはおっしゃるとおりだと思いますが、このコードが「コードゴルフ的な賢い1行」というほどに高密度なのは、もともとの数式に由来している面が大きいです。

プログラミング言語と読み上げの相性は、まったく考えたことがありませんでした。Pythonのようにインデントを活用する言語はより厳しくなるでしょうか。記号の読み上げはともかく、コード密度自体は少し高めのほうが短時間で読み上げできて便利そうにも思えたので、やや意外でした。いまいち想像力が貧困ですが、今後プログラミング言語の設計を考えるとき、ちょっとだけ読み上げを意識するようになりそうです。

banchobancho

数式の表現を置き換える意味で、簡潔だと思いました。

banchobancho

素晴らしい記事でした。また、Goで何か実装してみたくなりました。どんな実装に向くかについても、意見を聞いてみたく思います。
Rob Pikeは、nilについては、確信的に導入していると思います。調べてみても興味深いかもしれません。

Yoshiyuki KoyanagiYoshiyuki Koyanagi

コメントありがとうございます!

以前、音声信号処理について調べていたとき記事を偶然発見して、内容の正確さと簡潔さが強く印象に残っていました。そこで、今回投稿した記事の例にピッタリだと考えて引用させていただきました。

たしかに数式の形をなるべく保ったまま実装したというのは重要な前提です。記事を修正して一言注意書きを加えさせていただきました。ご指摘ありがとうございました。

ちなみに、pythonのインデントについて読みやすいかというと微妙です。というのも、タブの数からスコープが確定するので行数の少ないコードはとても読みやすいのですが、逆に行数が多くなるとネストも深くなってカーソルの位置を見失いがちです。

Goに限らず波かっこでスコープを区切る言語の場合、スコープを見失ったときはエディタの検索機能を使って最寄りの閉じる波かっこへカーソルを移動します。その後、対応する開き波かっこへ移動します。Vimの場合は%キーを押すとバランスしている波かっこへ移動できます。そうするとスコープの開始地点へ移動できるので、その行から再び読み始めることができます。

コードの読み上げという観点に興味を持っていただけて嬉しいです。長々と失礼しました。

Yoshiyuki KoyanagiYoshiyuki Koyanagi

コメントありがとうございます。Go言語の生い立ちについては詳しく知らないので、調べてみると面白いかもしれません。

それから、訳知り顔で記事を書いていると思われるかもしれませんが、私の知識や経験は大したことないので役に立ちそうなことはあまり語れないです...

ブルーレヰブルーレヰ

上記のGoコードであれば演算子オーバーロードは役に立つかもしれません。しかし、それは演算子オーバーロードを扱う人の善意に依存しています。人々を混乱させることに快感を覚えるスーパーハカーが+演算子と-演算子をひっくり返すのを防ぐことはできません。そして、そのコードを読んだ私は泡を吹いて倒れていたはずです。t2 := t1 + d1の1行を読み上げただけでは+演算子の意図がわからないからです。

Add というメソッド名で内部で引き算してたら同じことですよね?