自己紹介とセッション内容

小野輝也氏:「Goと定数」について話します。よろしくお願いします。始めに自己紹介をさせてください。小野輝也と申します。2021年の新卒入社で、ITインフラ本部のSRE部に所属しています。先週まではずっと技術系の研修を受けていて、まさに今週から配属になりました。

ふだんはGoでWebアプリケーションを書いたり、Rustでネットワークプログラミングをする同人誌を書いたりしています。Twitterもやっているので、よければフォローよろしくお願いします。

今回話す内容についてです。私はまだ配属されてから間もなくて、実際のDMMのプロダクション環境下で動くGoを書いたことはないので、Goの言語仕様に関わるような簡単な定数の話をしようと思います。内容は、Goにおける定数の基本と、その定数の上手な扱い方についてです。

Goの定数の基礎

Goの定数について、まず基礎をさらっと話そうと思います。(スライドを示して)左のコードのように定数がたくさん定義してあって、その型と値を表示するプログラムを実行してみます。

こちらが結果です。定数になれるのはboolean、rune、integer、floating-point、complex、stringのみの型になっています。Go言語では、マップや配列、スライスのようなものはベースにできません。

定数の定義の部分を空にすると、それより上の空ではない値をコピーするというものがあります。左のコードのcの部分です。「=右辺」が存在していないのですが、このようなものを書いた場合、それより上の空ではない、この場合でいうとbの「Welcome to DMM.go」は、cに同じ定義が書かれたものとみなされます。そのため、実行した結果は、bとまったく同じものが表示されるようになっています。

Goの定数はコンパイル時に値が決定されます。たくさんの型があるのですが、aに関してはfloatで、eに関しては複素数のかたちをしているのでcomplexになります。

(スライドを示して)最後のこれは、Goにおいてはtrueとfalseは予約語ではなくて、ビルトインパッケージで定義されている定数です。こんな感じで定義されているため「true=false」と書くと、右側のfalseはビルドインパッケージの定数であるfalseで、trueはパッケージで定義される定数です。このため、実行してみるとtrueの値はfalseという結果が得られます。

(スライドを示して)dに出てきたiotaに関してです。iotaはあまり目にする機会がないのですが、これは定数の生成器です。const(……)のカッコの部分に含まれる、定数定義の0から始まるインデックスを返します。同じ行に含まれるiotaは、同じ値を返してきます。

下のコードサンプルの例を見てみると、定数の定義a、b、cと並んでいますが、1つ目のaに関して、この行のiotaは0から始まるインデックスなので、0を返してきます。0プラス0で、aは0になります。2行目に関して、インデックスは2個目なので、1になり、bはiota掛ける10で10になります。

cに関しては定義が書いていないのですが、先ほど「定義が空の部分はその上にあるものをコピーする」というルールがありました。式をまるまるコピーしてくるので、cもiota掛ける10をまるまるコピーします。この時、iotaは定義の3つ目になるので、0から始まるインデックスだと2で、cの値は20になります。今のところはGoにもEnumはないので、このiotaは疑似的にEnumを作る時に利用したりします。

今回のセッション内容が生まれた背景

次に、この定数について今回話そうと思った経緯です。自分でGoのプログラムを書いていて、「あれ?」と思ったことがあったので、それを元にいろいろ調べて、ここで発表しようと思っています。

(スライドを示して)どんなプログラムかというと、任意の時間停止させられるtime.Sleep関数で、左のようにtime.Sleepで3掛けるtime.Secondと書くと、普通にコンパイルが通って、プログラムが3秒待って始動するような挙動をします。

一方で、右側のように変数aに対して3を割り当て、それに対してtime.Secondを掛けようとすると、「invalid operation、mismatched types」intとtime.Durationを掛けることはできませんよ、といったコンパイルエラーになり、このプログラムは通せません。

一見同じことをしているように見えるのに、一方はよくて、一方はダメな理由が気になったので、それについて後々話していこうと思います。

Goの計算ルール

まず、Goの計算ルールについて話そうと思います。Goのルールとして、型が一致しているもの同士が計算できるというものがあります。

そのため、int64とint8のように、同じ数値だったとしても、bit幅も型も異なるものは四則演算できません。できる言語もあるのですが、Go言語ではできないとなっています。これをしようと思ったら、明示的な型変換が必要です。

(スライドを示して)下の例を見てみると、int64のaとint8のbを足そうとすると「型が一致しませんよ」ということでコンパイルが通りません。この計算をしようと思うと、どちらか一方を明示的にキャストしてあげる必要があります。

先ほどの計算ルールがどんなものかを説明していく前に、少しだけ定数に関する基本的なことを話していきます。定数は、constキーワードを用いて定義が行われます。最初のスライドでも見たとおりです。「const a="hello"」のように定義できます。この時、左辺の「a」をnamed constant、右辺の「"hello"」をunnamed constantといい、いずれもuntyped constantと呼ばれるものに分類されます。そしてこれらのuntyped constantですが、リテラルのフォーマットをもとに、stringやfloatに分類されます。

例えば「"hello"」の例だと、ダブルクオートで囲まれているものはuntyped string constantです。「1.23」のように小数点を持つ数値のようなものだと、untyped float constantになり、虚数のような虚部を持つフォーマットになっていると、untyped complex constantになります。ここでstringやfloat、complexが出てきていますが、Go言語の型とはまた違ったものです。これはそれぞれuntypedで、つまり型がない状態になっています。

一方で、定数には型を明示できます。(スライドを示して)このように定義された定数はuntypedではなくてtypedのconstantになり、型はもちろんint型になります。

先ほど出てきたuntyped constantがいったい何かというと、明示的な型の情報を持たない定数です。このuntyped constantは、型が厳密に一致していないと四則演算できないルールからは少し外れます。

constが定義されている左の例です。これは先ほどのスライドの終わりで見たtypedのconstantです。それぞれ、intとfloat64のように、型が明示的に示されています。異なる型情報を持つため掛け算できず、mismatched typesのエラーになります。

一方で右の例だと、constのaとbがそれぞれuntypedのconstantになります。両方ともuntypedなので問題なく掛け算できて、コンパイルも通せます。

そしてこれらのuntyped constantですが、デフォルト型と呼ばれるものを持ちます。デフォルト型に関しては、untyped constantが持つ暗黙的な型になります。これは型が必要となった際に、明示された型情報がない場合、これらのデフォルト型が利用されます。

下の例をまた見てみましょう。これは、MyFloat64という型がdefined typeで定義されています。「1.5」というunnamedの定数に対して、変数であるaに対して代入されています。一方で、bは明示的に型がついている変数です。これらをそれぞれ型を出力させてみると、aに関しては「float64」で、bに関しては「MyFloat64」になっています。

aは明示的にこの変数に型情報がつけられてないので、「1.5」のデフォルト型となるfloat64がここで使われています。一方で、bに関しては明示されているので、明示的な型であるfloat64が使われています。

そしてこのデフォルト型ですが、シンタックス、つまり書かれているこのリテラルのフォーマットによって決定されます。小数点らしければfloat64になり、複素数らしければcomplexになったりします。

untyped constantsの例

ここからは、untyped constantsのいろいろな例をたくさん見ていきましょう。(スライドを示して)こんな感じにaからgまでの定数と変数が定義されていて、gだけ変数で定義してあります。aはuntypedなfloat、bに型情報があるのでtypedなconstantで、型はfloat64です。cはuntypedのbool、dはuntypedのrune、eはuntypedのcomplex、fはuntypedのintです。gは普通の変数で、typedでも型はintです。

これらをいろいろなペアに関して掛け合わせてみます。1つ目の例は、aとbなのでuntypedのfloat掛けるtypedのfloat64になります。これは問題なく掛け合わせられて、untypedとtypedの値を掛けると、typedに引きずられるfloat64になります。この掛け算の結果、生成される値はuntypedではありません。

次に、aとdを掛け合わせた例を見てみると、untypedのfloatとuntypedのruneになります。untypedのruneはint32のtype aliasなので、型は違っていますが、untypedとuntypedの掛け算なので、問題なく行えます。untyped同士を掛けた結果生成されるのも、同じくuntypedになります。

次のa掛けるeは、untypedなfloatとuntypedのcomplexの掛け算です。結果、やはりuntypedが生成されてuntypedのcomplexになります。

a掛けるfもまったく同じです。untypedのfloatとuntypedのintの掛け算で、生成されるものはuntyped floatになります。

そして、以下のダメな例を見てみます。b掛けるgなので、float64の定数のtypedとconstantとintの定数です。何度も見てきたとおり、型が異なっていて、両方ともuntypeではないので計算できません。

最後のaとcの例は、untypedのfloatとuntypedのboolの掛け算です。以下、同じuntypedとはいえ、数値と真偽値を掛け合わせることはできないので、これもコンパイルのエラーになります。

個別のuntypedに関する性質

ここからは、いくつか個別のuntypedに関する性質を見ていこうかと思います。先ほどたくさん出てきたuntyped floatですが、これは任意の精度と桁数を持っています。そのため、非常に高い精度で計算が可能です。

(スライドを示して)mathパッケージの定数「const.go」に数学的な定数が定義されていますが、こんな感じにEやPiやPhi、それぞれネイピア数、円周率、黄金比ですが、こんな感じで非常に長い桁数が定義されています。float64で表せる有効桁数はだいたい15桁ほどですが、それを大きく上回る桁数で書かれています。

一方で、これらの非常に精度の高い値であっても、出力したり最後の結果を出すために変数に代入してしまうと、float64またはfloat32になってしまいます。そのため、最終的にはいくらかの結果は失われてしまうのですが、最終的な結果に至るまでできる限りuntyped float同士で計算することで、最後まで高い精度を維持できるメリットがあります。

高い精度を維持できるほか、この任意の桁数を持つこともあるので、左下の例でいうと、aとbがそれぞれuntypedなconstantで定義されていて「1e1000」なので、10の1,000乗という極めて大きな値になっています。コメントアウトしてあるところのように、Printで出力しようとするとfloat64の範囲を超えてしまって、「表示できません」といったコンパイルエラーになります。

一方で、最終的に変数に代入されるか、出力されるタイミングでfloat64になるまでは任意の桁数で計算できます。(スライドを示して)こんな感じで、最終的にa割るbで10というfloat64の範囲に収まる数値にしてあげれば、それまではどんなに大きな桁数であっても計算できるようになっています。

(スライドを示して)次に「複数の型になれるuntyped」で、これは定数値によってはさまざまな型になれるものもあります。下の例を見てみようかなと思いますが「const a」でuntyped constantとして、「1.0 + 0i」が定義されています。デフォルトでは複素数のかたちをしているので、complexになります。実際に型と値を定義させてみると、デフォルト型であるcomplex128と表示されます。

しかし、虚部が0なのでaは実数です。「1.0」という小数点のかたちなので、float64と型が明示されている変数を代入することも可能で、float64と普通の型が表示されます。複素数であっても虚部が0でないと実数にはならないので、0の時のみ可能になります。

さらに「1.0」は少数部分が0なので、整数とまったく同じです。そのためfloat64の時と同じ感じで、intで定義されたcに関しても代入ができ、int型と共有させられます。このように、untypedの定数は、場合によってfloatやintなど、いろいろな型になれることがわかります。

Goの定数にはさまざまな仕組みが存在する

(スライドを示して)それでは最初に触れた、自分の発表のネタにしようと思った経緯についてです。左側は「3掛けるtime.Second」で、untyped int constantとtime.Secondというtime.Duration型の積になっています。

time.Secondという型ですが、timeパッケージで定義されているtypedのconstantです。そしてtime.Duration型がその型になるのですが、これはint64のdefinedタイプになっていて、こんな感じに定義されています。そのため、この3はuntypedで、time.Secondは数値なので、問題なく掛け合わせられます。

一方で、右側はaにint型がついてしまっているので、3をaに代入している時点で、aとtime.SecondのDuration型で型が違うということで、コンパイルできないといったことが起こります。これが最初の疑問に対する答えです。

まとめです。Goの定数には柔軟性・利便性を支えるさまざまな仕組みが存在しています。最初に紹介したiotaや、定数定義の省略による式コピーの他に、今回主に紹介したuntyped constantsなどがあります。

定数を定義する時にはuntypedをうまく活用して、型の決定をなるべく遅らせるのがグッドプラクティスであると考えられます。小数点であれば、任意の桁数や精度を途中できるだけ維持できるのが主な理由になります。

参考文献です。主にGoのLanguage Specificationで、言語の仕様書を参考に作成しました。私の発表は以上です。ここまで聞いてくださってありがとうございました。