every Tech Blog

株式会社エブリーのTech Blogです。

Goの組み込み関数 len() に詳しくなる

Goの組み込み関数 len() に詳しくなる

はじめに

こんにちは。デリッシュキッチン開発部でバックエンドエンジニアをしている鈴木です。

Go言語の組み込み関数len()は、一見シンプルに配列やスライスなどの「長さ」を返す関数ですが、その実装はコンパイラやランタイムレベルで特別な扱いを受けています。本記事では、lenの言語仕様からコンパイラ内部の処理フロー、SSA最適化、最終的なアセンブリコード、さらにはruntime内部構造体に至るまでを網羅的に順を追って詳しく説明していきます。

lenの仕様と定数評価

まず、Go言語仕様においてlen(x)がどのように定義されているかを確認しましょう。lenは組み込み関数であり、以下のような様々な型に適用できます。

  • 文字列 (string): バイト数(文字列の長さ)を返す
  • 配列 ([n]T またはポインタ *[n]T): 配列の要素数を返す(固定長n。ポインタ経由でも配列長は型で決まる)
  • スライス ([]T): スライスの現在の長さ(要素数)を返す
  • マップ (map[K]T): マップに定義されているキーの数を返す
  • チャネル (chan T): チャネルのバッファに蓄積されている要素数を返す

いずれの場合もlen(x)の返り値の型はintであり、その値は必ずint型に収まります。また、nilのスライス・マップ・チャネルに対する長さは常に0になることが明示されています。

さらにlenは場合によってコンパイル時定数として評価されます。具体的には、

  • 引数が文字列リテラルの場合、lenの結果はコンパイル時定数になります(文字列のバイト数をそのまま定数として扱う)。
  • 引数の型が配列型(または配列へのポインタ型)で、その引数の式にチャネル受信や非定数関数呼び出しを含まない場合、lencapの結果は定数とみなされます。この場合、その配列式自体は実行時に評価されません。言い換えれば、配列長がコンパイル時に判明していて副作用もないとき、コンパイラはlenを単なる定数として処理します。

以下のように、長さが決まっている配列リテラルに対するlenはコンパイル時定数となり得ます(Go仕様より)

const c1 = 1.0
const c2 = len([10]float64{2})         // [10]float64{2}には関数呼び出しがなく定数とみなせる
const c3 = len([10]float64{c1})        // c1自体は定数なのでlen(...)は定数

Fig. 1. コンパイル時定数となるlenの例

以上の仕様から、lenは他の言語における通常の関数というより演算子的な性質を持つ設計になっていることが分かります。その場で値を計算するというよりも、「この値(または型)の長さ」というビルトインのプロパティを返すものとして扱われます。

コンパイラ内部でのlen処理フロー

lenは組み込み関数としてコンパイラに特別扱いされます。Goコンパイラは構文解析・型チェック・SSA変換・最適化・コード生成といった複数のフェーズを経てソースコードを機械語に変換します。ここではlenがソースからどのようにコンパイルされていくか、主要な段階ごとに追ってみましょう。

Universeブロックへの組み込み関数登録

GoではUniverseブロックと呼ばれる特別な領域に、組み込みの定数・型・関数があらかじめ定義されています。lenもこの中で定義されており、コンパイラ起動時に下記のように登録されます(lenは内部的な演算コードOLENに対応付けられます)。

{"append", ir.OAPPEND},
{"cap",    ir.OCAP},
{"clear",  ir.OCLEAR},
{"close",  ir.OCLOSE},
{"complex",ir.OCOMPLEX},
{"copy",   ir.OCOPY},
{"delete", ir.ODELETE},
{"imag",   ir.OIMAG},
{"len",    ir.OLEN},
{"make",   ir.OMAKE},
...

Fig. 2. 組み込み関数と内部コードの対応(lenir.OLENとして登録)

上記はコンパイラ内部 (cmd/compile/internal/typecheck/universe.go) でのbuiltinFuncs配列の一部です。コンパイラはこれを使って、ソース中でlenという識別子を見つけた際に通常の関数ではなく組み込み関数として処理します。実際、構文解析の段階でlen(x)という構文を読み込むと、lenは単なる関数呼び出しではなく「組み込み関数lenの適用」という特別なノードとしてAST(抽象構文木)に格納されます。

型チェックとAST変換

構文解析後、コンパイラはAST上で各ノードの型チェックを行い、不正な操作を検出したり必要な変換を施したりします。lenについては関数呼び出しではなく単項演算子的な扱いになるため、型チェック段階でASTノードが変換されます。具体的には、len(x)に対応するノードはir.UnaryExpr(単項式)に置き換えられ、その操作種別としてir.OLENが設定されます。

また型チェック中に、lenの引数の型が正しいかどうかを検証します。Goコンパイラ内部では先述の通りlenが適用可能な型を予めフラグテーブルokforlenで定義しており、例えば配列・チャネル・マップ・スライス・文字列に対してlenが使えるよう真に設定されています(src/cmd/compile/internal/typecheck/universe.go)。

okforlen[types.TARRAY]  = true
okforlen[types.TCHAN]   = true
okforlen[types.TMAP]    = true
okforlen[types.TSLICE]  = true
okforlen[types.TSTRING] = true

Fig. 3. 組み込み関数lenが適用可能な型の定義(コンパイラ内部テーブル)

型チェック関数typecheck1内では、ノードの種類がOLEN(またはOCAP)の場合に専用の処理に分岐し(src/cmd/compile/internal/typecheck/typecheck.go)、関数tcLenCapで詳細なチェックと型設定を行います(src/cmd/compile/internal/typecheck/expr.go)。その実装コードの概略をFig. 4に示します。

switch n.Op() {
    ...
    case ir.OCAP, ir.OLEN:
        n := n.(*ir.UnaryExpr)
        return tcLenCap(n)
}

// tcLenCap typechecks an OLEN or OCAP node.
func tcLenCap(n *ir.UnaryExpr) ir.Node {
    n.X = Expr(n.X)
    n.X = DefaultLit(n.X, nil)
    n.X = implicitstar(n.X)
    ...
    var ok bool
    if n.Op() == ir.OLEN {
        ok = okforlen[t.Kind()]
    } else {
        ok = okforcap[t.Kind()]
    }
    if !ok {
        base.Errorf("invalid argument %L for %v", l, n.Op())
        n.SetType(nil)
        return n
    }
    n.SetType(types.Types[types.TINT])
    return n
}

Fig. 4. len/capノードの型チェック処理(不正な型ならエラーし、戻り値型をintに設定)

上記のように、まずlenの引数n.Xを再帰的に式として型チェックし(Expr(n.X)等)、デフォルトのリテラル型適用やポインタ間接の暗黙的挿入(implicitstar)を行った後、okforlenテーブルを参照して引数型が許容されるか検査しています。もし許可されない型であればエラーを報告し(invalid argument for len)、ノードの型をnilにして終了します。問題なければ、lenノード自体の型(n.Type)をint型に設定します。これにより、この時点でコンパイラは「len(x)の結果はintである」ことをAST上で確定させるわけです。

型チェック段階までで特に重要なのは、len実際の関数呼び出しではなくコンパイラ内部で特別扱いされる点です。Goの組み込みbuiltin.goにはfunc len(v Type) intと宣言されていますが実体はなく、IDEなどで定義を見ても空っぽな関数が出てくるだけです。これはコンパイラがビルトインを直接処理するためで、lenはユーザが実装を見るような通常の関数ではないのです。

SSA形式への変換(中間表現)

すべての型チェックが終わると、次はSSA形式への変換(静的単一代入形式の中間表現)に入ります。Goコンパイラでは各関数ごとにASTからSSAを構築し、最適化を行った後、機械語の生成へと進みます。lenについてはSSA生成時にさらに各型ごとに扱いが分岐します。その処理を示したのが以下のコードです(src/cmd/compile/internal/ssagen/ssa.go)

// expr converts the expression n to ssa, adds it to s and returns the ssa result.
func (s *state) expr(n ir.Node) *ssa.Value {
    ...
    switch n.Op() {
    case ir.OLEN, ir.OCAP:
        n := n.(*ir.UnaryExpr)
        // Note: all constant cases are handled by the frontend. If len or cap
        // makes it here, we want the side effects of the argument. See issue 72844.
        a := s.expr(n.X)
        t := n.X.Type()
        switch {
        case t.IsSlice():
            op := ssa.OpSliceLen
            if n.Op() == ir.OCAP {
                op = ssa.OpSliceCap
            }
            return s.newValue1(op, types.Types[types.TINT], a)
        case t.IsString(): // string; not reachable for OCAP
            return s.newValue1(ssa.OpStringLen, types.Types[types.TINT], a)
        case t.IsMap(), t.IsChan():
            return s.referenceTypeBuiltin(n, a)
        case t.IsArray():
            return s.constInt(types.Types[types.TINT], t.NumElem())
}

Fig. 5. len/capノードのSSA変換処理(引数の型に応じて異なるSSA命令や定数に展開)

上記のように、SSA生成フェーズではlen(およびcap)に対し以下のような分岐処理が行われます。

  • 引数が配列型の場合(デフォルトケース) — n.X.Type().NumElem()で配列要素数を取得し、単にその値を定数(SSA上の定数値)として返します。すなわち、コンパイル時点で配列長が分かる場合、SSA上では既にリテラルな定数となります。例えば[5]int型の変数であれば、そのlenは5という定数になります。この実装ではコンパイル時に型オブジェクトからNumElem()メソッドで配列長(内部的には型情報中のBoundフィールド)を取得しています(Fig. 5中のt.NumElem()部分)。
  • 引数がスライス型の場合 — ssa.OpSliceLenというSSA命令を生成します(capの場合はssa.OpSliceCap)。これはスライスの長さ情報を取り出す専用のSSA命令です。
  • 引数が文字列型の場合 — ssa.OpStringLenというSSA命令を生成します。文字列についてはcapは存在しないのでlenの場合だけです。
  • 引数がマップ型またはチャネル型の場合 — s.referenceTypeBuiltinという専用のヘルパー関数を呼び出します。マップとチャネルは内部実装が参照型であるため、これらについては汎用的な処理が取られています(この部分は次節で詳説)。

このSSA段階の分岐により、lenの動作は引数の型ごとに最適化されます。配列長は定数畳み込みされ、スライス・文字列長はSSA上で専用命令(後述のとおり最終的には単なるメモリアクセスに変わる)となり、マップ・チャネル長は多少複雑な処理(nilチェックを含むコード)に展開されます。

コード生成と最適化

SSAフォームへの変換後、コンパイラはアーキテクチャ固有の最適化・コード生成を行います。lenに関しても、この段階でSSA命令が具体的な機械語に置き換わります。各型における主な変換は以下のとおりです。

  • 配列: SSA上既に整数定数になっているため、そのまま即値リテラルとしてコード中に埋め込まれます。実行時の計算は発生しません。
  • スライス・文字列: OpSliceLenOpStringLenといったSSA命令は、実行時には構造体の該当フィールドを読み取る単純な命令に変換されます。Goにおけるスライスは実体として内部にポインタ・長さ・容量のフィールドを持つ構造体で表現されますし、文字列もデータへのポインタと長さを持つ構造体です。したがって、例えばスライスの長さ取得はメモリ上で「ポインタの直後にあるint値」を読み出す操作になります。GoコンパイラはSSA最適化のLate Expansion段階でこれらを展開し、ポインタ演算で適切なオフセットから長さを取り出すコードにします(典型的にはポインタサイズ分オフセットした位置がlenフィールドです)。実際、x86-64アーキテクチャではスライス長の取得は1命令で完了します。例えば「レジスタに入っているスライス構造体の長さフィールドを別のレジスタに移す」といった具合です。(具体例は後述)
  • マップ・チャネル: これらも内部的にはポインタで表現された参照型で、runtimeパッケージ内の構造体(hmaphchan)として実装されています。lenを求める場合、構造体の先頭に格納されたフィールド(マップなら要素数count、チャネルならキュー中の要素数qcount)を読み出せば良いのですが、注意点はポインタがnilの可能性です。len(nil)が0を返すという仕様を守るため、コンパイラはnilチェックをコード中に組み込みます。SSAで生成されたreferenceTypeBuiltin関数内の処理はまさにそれを実現しています。

Fig. 6はreferenceTypeBuiltin関数内の該当部分を抜粋したものです(src/cmd/compile/internal/ssagen/ssa.go)。このコードは「マップ/チャネルのlen/cap用のSSAコード」を生成します。

lenType := n.Type()
nilValue := s.constNil(types.Types[types.TUINTPTR])
cmp := s.newValue2(ssa.OpEqPtr, types.Types[types.TBOOL], x, nilValue)
b := s.endBlock()
b.Kind = ssa.BlockIf
b.SetControl(cmp)
b.Likely = ssa.BranchUnlikely

bThen := s.f.NewBlock(ssa.BlockPlain)
bElse := s.f.NewBlock(ssa.BlockPlain)
bAfter := s.f.NewBlock(ssa.BlockPlain)

...
switch n.Op() {
case ir.OLEN:
    // length is stored in the first word for map/chan
    s.vars[n] = s.load(lenType, x)
...
}
return s.variable(n, lenType)

Fig. 6. マップ/チャネルに対するlen/capのSSA展開(nilチェックと長さフィールド読み取りの生成)

この生成ルーチンでは、まず引数ポインタxnilと等しいか比較するSSA値を作り(OpEqPtr)、if文ブロックを構築しています。cmpが真(つまりポインタがnil)の場合は「len=0」を返す経路、偽の場合は実際に長さを読み取る経路に分岐する形です。実際の長さ読み取りは、s.load(lenType, x)によって行われます。これは与えられたポインタx(マップまたはチャネル)からlenTypeint型)の値を読み取る、つまり構造体の先頭のintフィールドを読み出すことを意味します。上述のようにマップでは先頭にcount、チャネルでは先頭にqcountがあるため、ちょうどそれがlenの返すべき値になっています。

最後にreturn s.variable(n, lenType)とすることで、この計算結果をSSA上の変数(仮想レジスタ)としてlenノードに対応付けています。こうしてSSA上では、nil分岐と読み取りコードが表現され、最終的にバックエンドでこれが具体的な分岐命令とメモリアクセス命令に変換されます。

型ごとのlenの挙動と実装詳細

以上、コンパイラ内部での変換処理を見てきました。ここで改めて、各データ型についてlenがどのように動作し、最終的にどんなコードになるのかをまとめます。

配列に対するlen

配列型([n]T)に対するlenコンパイル時に決まる定数です。配列の長さnはその型の一部であり、Goではコンパイル時に配列サイズが確定しています。したがって、配列変数や配列リテラルに対するlenは、コンパイラがその場でnという定数値に置き換えます。

var a [5]int
fmt.Println(len(a))  // コンパイル時にlen(a)は5に置き換えられる

Fig. 7. 配列に対するlenの使用例(コンパイル時に定数5に置き換わる)

上記len(a)は実行時の計算を必要とせず、生成されるコード上では定数5として扱われます。仮にポインタ型*[5]intであっても、指している配列長は5と決まっているため、同様にlen(p)は5となります。ただし、ポインタがnilであっても配列長自体は型から分かるため、(*[5]int)(nil)に対するlenも5を返します(もっとも、そのようなコードを書くことは稀ですが、仕様上そうなっています)。

コンパイラ実装的には、配列長は型オブジェクト内のフィールド(types.Array.Boundsrc/cmd/compile/internal/types/type.go)に保持されており、NumElem()メソッド(src/cmd/compile/internal/types/type.go)で取得可能です。Fig. 8にGoコンパイラ内部の配列型定義の抜粋を示します。

// 配列型の定義(コンパイラ内部表現)
type Array struct {
    Elem  *Type // 要素の型
    Bound int64 // 要素数(未確定の場合<0)
}

func (t *Type) NumElem() int64 {
    t.wantEtype(TARRAY)
    return t.Extra.(*Array).Bound
}

Fig. 8. 配列型Arrayの定義と長さを返すNumElemメソッド

このようにしてコンパイラは配列長を取得します。結果として、配列のlen呼び出しは単なる定数参照となり、ランタイムコストはゼロです。

スライスに対するlen

スライス([]T)は可変長のシーケンスを表す構造体で、内部的にはポインタ(配列データへの参照)、長さLen、容量Capの3つのフィールドから構成されています。Goの標準パッケージreflectでは以下のように定義されています(src/reflect/value.go)。

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

Fig. 9. スライスの内部構造体SliceHeader

スライスに対するlen(s)は、この構造体のLenフィールドの値を返します。コンパイル後の機械語では、スライスの長さ取得は対応するフィールドを読み出すだけの操作になります。例えばx86-64の場合、スライスがレジスタやスタック上に載っていれば、そのLen部分をMOV命令で読み込むだけで済みます。

コンパイラはSSA命令OpSliceLenでスライス長取得を表し、最終的なコード生成時にそれを適切なオフセットの読み出し命令に置き換えます。すでに述べた通り、Lenフィールドは構造体先頭のポインタの直後に位置するため、ポインタのサイズ分オフセットしたメモリアドレスからint値を読み取ればlenが得られます。コンパイラはこのoffset計算と読み出しを自動的に行います。

nilスライスの場合でも、内部表現上はData=nil, Len=0, Cap=0という構造体値になっています。したがってlen(nilSlice)もメモリ上0を読み取るだけで、特別な分岐なしに0が得られます。スライスに関しては、nilであっても長さフィールドは常に0にセットされているため、余分なnilチェックは不要という点がマップ/チャネルとは異なります。このため、例えば関数の引数でスライスを受け取る場合、コンパイラはnilかどうかに関わらず単一の命令で長さを取得するコードを生成します。

文字列に対するlen

文字列(string)は不変のバイト列を表す型で、内部的にはデータへのポインタと長さを持つ点でスライスに似ています(容量がないぶんスライスよりフィールドが一つ少ない構造体です)。len(str)文字列のバイト数(メモリ上の長さ)を返します。こちらも実行時には文字列構造体の長さフィールドを読み取るだけで、スライス同様に1命令で取得可能です。

文字列はnilという値は存在しません(空文字列""はLen=0ですがDataフィールドはスライスとは異なりゼロではない可能性があります)。しかし言語仕様上、文字列はゼロ値ではDataフィールドが特定のnilではなく別の特殊な場所を指している実装になっていますが、長さは0となっています。そのためlen("")は0を返し、その他の場合も格納されたバイト数を返します。

マップに対するlen

マップ(map[K]V)は参照型で、内部的にはハッシュマップ構造体(runtime.hmap型)のポインタとして実装されています。len(m)はマップの要素数(エントリ数)を返します。ランタイムのhmap構造体定義を見ると、先頭にcountというフィールドがあり、そこに現在の要素数が保持されています。以下にその一部を示します(src/runtime/map_noswiss.go)。

type hmap struct {
    count     int // # live cells == size of map. Must be first (used by len() builtin)
    flags     uint8
    B         uint8
    noverflow uint16
    hash0     uint32
    buckets    unsafe.Pointer
    ...
}

Fig. 10. マップの内部構造体hmap(先頭のcountに要素数を保持)

このcountこそがlenの返す値です。コンパイル後のコードでは、マップのポインタ(*hmap)がレジスタなりメモリなりにあるとして、そのアドレスに対して先頭のint値を読み出す処理になります。例えばx86-64では、マップポインタがレジスタRDIに入っている場合、MOVQ (RDI), AXのような命令で先頭8バイト(64ビット)のcountをAXレジスタに読み込み、それを返り値とする、といったコードになります。

しかしマップの場合、スライスと違いポインタがnilである可能性があります。nilマップは要素数0と定義されているため、nilを扱う際は0を返さねばなりません。nilポインタのまま先頭を読みに行けばメモリアクセス違反になるため、コンパイラは事前にnilかどうかチェックするコードを生成します(Fig. 6参照)。実際のアセンブリでは、CMPQ RDI, $0(マップポインタが0か比較)といった命令でnil判定し、ゼロなら長さ0をセットして終了、それ以外ならMOVQ (RDI), AXcountを読み込む、といった分岐になります。

チャネルに対するlen

チャネル(chan T)も参照型で、内部的には双方向キュー構造runtime.hchanのポインタで表現されています。len(ch)はチャネルバッファに蓄積している要素の個数を返します。ランタイムのhchan定義では先頭にqcountというuint値があり、これが現在のバッファ内データ数を保持しています(src/runtime/chan.go)。

type hchan struct {
    qcount   uint           // total data in the queue
    dataqsiz uint
    buf      unsafe.Pointer
    elemsize uint16
    closed   uint32
    elemtype *_type
    ...
}

Fig. 11. チャネルの内部構造体hchan(先頭のqcountにキュー内要素数を保持)

このqcountlen(chan)の返り値になります。実装上はマップと同様、チャネルポインタの先頭ワードを読み出すだけです。ただしチャネルもnilポインタの可能性があるため、やはりnilチェックを含むコードになります。nilチャネルは長さ0と定義されていますので、nilであれば0を返すよう分岐します。非nilならqcountを読み取ります。

SSAから最終アセンブリへの変換例

最後に、実際のアセンブリコード上でlenがどのようになるか、簡単な例を示します。以下にスライスとマップのlenを返す関数を想定し、x86-64アセンブリ出力を例示します。

// func lenSlice(s []int) int
lenSlice:
    MOVQ    RSI, AX        // s.Len(RSIに入っている長さ)をAXレジスタに移動
    RET                    // そのまま返り値として返す

// func lenMap(m map[int]int) int
lenMap:
    CMPQ    RDI, $0        // mポインタ(RDI)がnilか比較
    JEQ     .Lnil          // ==0(nil)の場合.Lnilラベルへジャンプ
    MOVQ    (RDI), AX      // *m.count(マップ先頭のcountフィールド)をAXにロード
    RET
.Lnil:
    XORL    AX, AX         // AXを0クリア(len=0)
    RET

Fig. 12. lenのアセンブリ出力例(スライスの場合は1命令、マップの場合はnilチェック+読み取り)

上記のように、スライス長取得はLenフィールドが保持されているレジスタ(ここでは関数呼出規約上RSIに格納)からそのままAXへコピーするだけで完了しています。一方、マップ長取得ではRDIレジスタにマップのポインタが渡されており、まずそれがゼロかどうか比較した後、ゼロでなければメモリアドレスRDIが指す先の値(count)を読み取っています。nilの場合はジャンプしてAXレジスタをゼロクリアすることで0を設定し、リターンしています。

このように、実行時コードにおいてlenは非常に低コストな操作です。実際、スライスや文字列のlen取得はオーバーヘッドのない単なるメモリ参照となり、マップやチャネルでもnil判定+メモリ参照程度に展開されます。この最適化された生成により、例えばループの終了条件にlen(slice)を毎回書いても問題ない(自明なインライン展開なので)のはこのためです。

コンパイラは場合によってはさらなる最適化も行います。例えばループ内で長さが変わらないスライスに対して毎回lenを呼んでいると、最適化でループ前に一度だけlenを計算しレジスタに保持する、といったことも行われます。また、コンパイラはrangeループのコード生成時にも内部でlenを使いますが、これも一定の場合で定数とみなして評価を省略する挙動があります。

まとめ: なぜlenは演算子的に設計されているのか

以上を踏まえ、最後にlenが「関数」ではなく言語組み込みの演算子のように設計されている理由についてまとめます。

1. 多様な型に対応するため

lenは配列、スライス、文字列、マップ、チャネルといった複数の組み込み型に対して使えます。もし通常の関数として定義しようとすると、これらすべての型について関数やメソッドを用意する必要があり煩雑です。しかし組み込み関数としてコンパイラが特別扱いすることで、統一した名前lenで様々な型の「長さ」を取得できるようになっています。ジェネリクスが導入された現在でも、lenはビルトインのままです(型パラメータPに対してlen(x)が使えるのは、その型集合内のすべての具体型についてlenが定義されている必要がある、という形で言語仕様に組み込まれています)。

2. 効率のため

上述のとおり、lenの実装は非常に効率的に最適化されます。コンパイル時に分かる長さは定数化し、実行時に必要な場合も単なるフィールドアクセスや軽微な分岐で済みます。これはコンパイラレベルでlenを演算子的に扱っているからこそ可能となる最適化です。通常の関数呼び出しであればインライン展開や最適化の制約が生じえますが、lenは言語レベルで特別扱いされるためそのようなオーバーヘッドがありません。

3. コードの簡潔さと安全性

lenを組み込みとする設計は、言語利用者にとっても扱いやすさと安全性につながっています。例えばlenは定義上panicを起こし得ません(どんな引数でも0以上の整数を返す)し、nilも安全に処理されます。仮にlenが通常の関数であった場合、nil参照のチェックや異常系処理をユーザが意識する必要があったかもしれませんが、現在の設計ではそうした心配は不要です。また、ビルトインであるためユーザはlenをオーバーライドしたり別の意味に使ったりできません。これにより、常にlenという表記は言語仕様どおりの意味を持ち、コードの可読性・一貫性が保たれます。

4. 内部実装のカプセル化

lenを組み込み関数としたことで、各データ構造の内部実装(例えばマップの構造体やチャネルの構造体)を直接公開せずに「要素数」という情報だけを提供できます。ユーザはこれら構造の詳細を気にせずlenを使えますし、仮に将来内部実装が変わってもlenの振る舞いは保証されます。実際、Goのランタイム実装は自由に変更可能ですが、lenの結果だけは常に正しくなるようコンパイラとランタイム側で約束しています。

以上の理由から、Goのlenは言語レベルで特殊扱いされる演算子的な組み込み関数として設計されています。そのおかげで、私たちはlenをまるで配列やスライスなどに対する演算子のようにどんな場面でも安心して使うことができます。実装上も無駄なコストがなく、「長さを求める」という非常に基本的な操作を高速かつ安全に行うことをGoは保証しているのです。

おわりに

本記事では、Goにおける基本的な組み込み関数lenの内部挙動についてまとめてみました。普段何気なく使っている関数ですが、調べていくと知らないことばかりで驚きの連続でした。Goにはlenの他にもいくつか同様の組み込み関数(cap, new, makeなど)があります。これらもlenと同じくコンパイラのUniverseブロックに定義され、内部で特別に処理されます。Go言語の公式ドキュメントや実装コードを読むことで、ビルトインがどのように扱われているかさらに理解が深まるでしょう。本記事が、lenという身近な関数の背後にある言語仕様とコンパイラ技術について理解を深める一助になれば幸いです。


参考文献: