Go
golang
ポエム

Go言語の標準ライブラリに学ぶ関数の最適化

標準ライブラリを読もう!

事、コーディングに関して、ごくごく一般的な話であろうとは思いつつ、
改めて自分にはできてなかったなぁ、と学んだ話です。

弊社では、週1の頻度でGo言語の標準ライブラリを読む勉強会を行っています。
現在はstringsパッケージを輪読中で、まぁ、アセンブラありビット演算ありと、
一筋縄ではいかない現場のコードを日々楽しんでいます。

Go言語の標準ライブラリ輪読に期待している効果は幾つか想定しているつもりですが、
シンプルな言語仕様故に、

  • 可読性が高い、読める
  • コードゴルフな議論に陥りにくい
    • どんな言語でも通用するような本質的な議論がしやすい
  • Go言語自身で書かれている標準ライブラリだって読める
    • 言語を造った人達の気持ちが学べる
    • そんな良質なコードからクンフーが積める
    • 標準ライブラリに精通できる

等の学びを期待しています。
そんな輪読会で個人的に得た学びの中から、印象的なものを幾つか書き残そうと思います。

各関数の挙動についての説明は、リファレンスとソースのコメントに譲ります。
Go言語はソースコードもさることながら、リファレンスも可読性が高いので是非ご一読下さい。

できるだけなにもしない

func IndexAny(s, chars string) int

この関数はcharsの文字がsに含まれない場合には-1を返す仕様で、特にcharsが空文字の場合には
-1が返る仕様になっています。

で、シンプルにこんな感じで書かれています。

// IndexAny returns the index of the first instance of any Unicode code point
// from chars in s, or -1 if no Unicode code point from chars is present in s.
func IndexAny(s, chars string) int {
    if chars == "" {
        // Avoid scanning all of s.
        return -1
    }

他にも、func genSplit(s, sep string, sepSave, n int) []stringとか、こんな感じで。

// Generic split: splits after each instance of sep,
// including sepSave bytes of sep in the subarrays.
func genSplit(s, sep string, sepSave, n int) []string {
    if n == 0 {
        return nil
    }

素朴で当たり前の様に見えますが、何もしないということがいかに大切か身に沁みます。
なにもしなければ、バグもボトルネックも生まれません。
手を抜かずに、きちんと場合分けをして、なにもせずに済ませたいものです。

最適化できる場合を丁寧に選り分ける

stringsパッケージの中でよく見かけるのが、ASCII文字に対する最適化です。
stringsパッケージの関数はインターフェースとしてruneを許容するものが多いのですが、
その実装はASCIIだけの場合と、それ以外を分けて書いてある場合がほとんどです。

func ToUpper(s string) string

// ToUpper returns a copy of the string s with all Unicode letters mapped to their upper case.
func ToUpper(s string) string {

...

    if isASCII { // optimize for ASCII-only strings.
        if !hasLower {
            return s
        }
        b := make([]byte, len(s))
        for i := 0; i < len(s); i++ {
            c := s[i]
            if c >= 'a' && c <= 'z' {
                c -= 'a' - 'A'
            }
            b[i] = c
        }
        return string(b)
    }

...

ちょっとstringsパッケージからは外れていますが・・・
func ToLower(r rune) rune

// ToLower maps the rune to lower case.
func ToLower(r rune) rune {
    if r <= MaxASCII {
        if 'A' <= r && r <= 'Z' {
            r += 'a' - 'A'
        }
        return r
    }
    return To(LowerCase, r)
}

これも、素朴な実装で済むものと、そうでないものが丁寧に分けて書かれています。
「ぼくがかんがえたさいきょうのアルゴリズム」でゴリ押しするのではなく、
ごく単純に、簡単に処理できるものとそうでないものを分けて処理する・・・という当たり前の実装です。

最後の手段として、一般的な場合を処理する

func ToLower(s string) string
func Map(mapping func(rune) rune, s string) string

func ToLower(s string) string {
    isASCII, hasUpper := true, false
    for i := 0; i < len(s); i++ {
        c := s[i]
        if c >= utf8.RuneSelf {
            isASCII = false
            break
        }
        hasUpper = hasUpper || (c >= 'A' && c <= 'Z')
    }

    if isASCII { // optimize for ASCII-only strings.
        if !hasUpper {
            return s
        }
        b := make([]byte, len(s))
        for i := 0; i < len(s); i++ {
            c := s[i]
            if c >= 'A' && c <= 'Z' {
                c += 'a' - 'A'
            }
            b[i] = c
        }
        return string(b)
    }
    return Map(unicode.ToLower, s)
}

s stringを1文字毎にイテレーションするのがMap関数で、unicode.ToLowerが処理の実態になります。
この手前まで、ASCII-only stringsの場合と、既に全部小文字だった場合に何もしない場合分けが入っていて、runeを含む一般的な変換は最悪のケースとして最後に書かれています。

func IndexAny(s, chars string) int

// IndexAny returns the index of the first instance of any Unicode code point
// from chars in s, or -1 if no Unicode code point from chars is present in s.
func IndexAny(s, chars string) int {
    if chars == "" {
        // Avoid scanning all of s.
        return -1
    }
    if len(s) > 8 {
        if as, isASCII := makeASCIISet(chars); isASCII {
            for i := 0; i < len(s); i++ {
                if as.contains(s[i]) {
                    return i
                }
            }
            return -1
        }
    }
    for i, c := range s {
        for _, m := range chars {
            if c == m {
                return i
            }
        }
    }
    return -1
}

これも、ASCII-onlyである場合とそうでない場合を分けて、検索対象がからである場合には何もせずにreturn
しています。最後のforループは総当りの検索になっていて、これをやるのは最悪の場合に限られています。

まとめ

  • 処理が少なくて済むパターンを丁寧に分ける
  • なにもしなくて済む場合には、何もしない
  • 最悪のケースは、最後にやる

各々の場合分けも、漏れなく重複無く、イワユルMECEにできるよう気をつけねばなりません。
処理のレイヤーやモジュール構成等、的確に矛盾なくできるよう日頃から意識してないと、
この様な美しい書き方はできないです。少なくとも、一通りの実装ができて、テストも書けて、
リファクタリングがしっかり終わった後でないとこういう最適化はできないと思います。ただ、
これもコーディングのごく常識的な話ですね。

特別、Goに限った話ではない当たり前の事ばかりでしたが、日頃から丁寧にできているかと自問すると、
反省すべき部分が多々あり、クンフーが足りないと感じます。

第一線で活躍するエンジニア達のコードは、当たり前が当たり前にできているコードだと思いました(小並感)。