Quantcast
Channel: Go Advent Calendarの記事 - Qiita
Viewing all 25 articles
Browse latest View live

正しさとGo

$
0
0

はじめに

Goの良いところは、最低限の文法を知っていればコードを上から順番に読むことで詳細を容易に理解できることです。
文法の中にシンタックスシュガーや特別な省略が許されていないため多様な表現になることはありません。

そのためGoを書ければGoの本体と標準ライブラリを読むことができます。

しかし以下の原因により、これらの利点を守ることが難しくなることがあります。

  • DSL
  • フレームワーク
  • 抽象化

これらは設計として新たな制約を課すことで品質向上や実装を容易にするためのものです。
またこれらを採用する論理立てた 正しい 理由が存在します。

DSL

DSLを提供するツールとして、DIのための wire があります。

GoでDIを実現するためには多くの実装を必要とするため、実装量を減らすためにもDIツールが求められてきました。

これは 正しい です。

しかし一方でDSLはコードを読む人間に言語以上の知識を求めます。
また詳細な挙動をすべて理解して追うことは難しいです。恐らく wire の内部実装をすべて理解して利用している人は少ないでしょう。

フレームワーク

Goの net/httpパッケージは一般的なWebフレームワークに比べて機能が少ないためコードが冗長になります。
そのためWebフレームワークが求められてきました。

これは 正しい です。

しかし一方でWebフレームワークはコードを読む人間に言語以上の知識を求めます。
ときには標準ライブラリのnet/httpとの接続が難しくなることもあります。
また詳細な挙動をすべて理解して追うことは難しいです。恐らく ginecho などの内部実装をすべて理解して利用している人は少ないでしょう。

Webフレームワーク以外にもORマッパーやテスト用のアサーションツールなども同様です。

抽象化

テスタビリティやSOLID原則のために抽象化することがあります。
Clean ArchitectureのInput Port、Output PortやDDDのレイヤードアーキテクチャのRepositoryなどもこれに該当します。

これは 正しい です。

しかし一方で抽象化は本質的なロジック以外のコードが増え、コードの可読性を落とすことがあります。
またどうしても完全な動作を理解しようとしたときは抽象化された先の実装を追う必要がでてきます。

正しさと悪

これらの制約の 正しさ は、正しいため反論をすることは難しいです。
またDSL、フレームワーク、抽象化もGoで実装されているため勉強して理解しないことを とすることが簡単にできます。

しかしこれには毅然として立ち向かう必要があります。

正しさとGo

私は、Goは制約の代わりに読みやすさでコード品質を担保することに比重をおいた言語だと考えています。

例えばJavaでは、検査例外により呼び出し側にエラーハンドリングをしなければいけないという制約を課しました。
Goでは必ずerrorを返すだけです。それを _ で捨てることや受けないこともできてしまうため制約がとても弱いです。しかしほとんどの場合はコードレビューで気がつくことができます。

これだけを聞くと、「コードレビューは見逃す可能性があるのだから、正しく設計をすることでコンパイル時に気がつけるようにするべきだ」 となり、Goの姿勢はエンジニアとして正しくないように感じます。
そのため他の言語を主軸に置いている人からの批判がGoではどうしても多くなりがちです。

しかし正しさを求めることは 制約の代わりに読みやすさでコード品質を担保する ということから離れていくことでもあります。

これらを両立してコーディングしていくことがGoを書く上では、他の言語以上に大切になります。

フレームワークを採用しないことが正しいのか

では、フレームワークを採用しないことが正しいのでしょうか?

以下の場合は採用すべきです。

  • 学ぶ範囲を適切に絞れているツールである
  • ツールの価値が学習コスト以上にある
  • 適切な代替手段がない
GRPCは、Protocol Buffersで記述したルールに基づきコードが生成されます。このルールを覚えることは容易です。
またMicroservicesの文脈では、GRPC前提であることが多いため適切な代替手段がありません。

そのため採用するべきです。

と言うことはできますが、他のWebフレームワークなどでも同様の 正しさ を主張することは可能でしょう。

そのため状況と程度の問題であり、最終的にはアーキテクトのセンスに委ねられます。

まとめ

あるGo本体に近いレイヤーでコードを書いている人から、この 正しさ を嫌う意見を聞いたことが、この話を考え始めたきっかけです。
逆に他の言語でWebアプリケーションを書いてきた方は、この 正しさ を好む傾向にあると思います。
私はどちらの意見も間違ってはいないと思います。

ただしインターネット上にはこの 正しさ が溢れており、新しい技術や目の前の課題を解決してくれるツールは素晴らしく見えます。
そこで新しいツールを導入する際には、一度立ち止まりGoとはこのような言語であったということを思い出し、課題設定が正しいかを改めて考え直してみてください。

それを踏まえてプロダクトと組織のことを考え、長期的に負債を生まないように適切な解決手段を提案しなければなりません。

これらのバランスを考え技術選定できることがアーキテクトの実力の見せ所であり、本当のスキルではないでしょうか。

蛇足

  • 今回はDSL、Webフレームワーク、抽象化に対してどのように対応したかを書いていませんが、また別の機会があればそれぞれ別の記事を書こうと思います。その正しさについて別の課題設定をすることで解決しています。
  • この話は別の視点で考えると、読むコストと書くコストのどちらに比重をおくべきかの議論なのかもしれません。

dept を使った Go ツールの依存管理

GoでSSH Managerを作成した際の知見

Go言語は沼

$
0
0

Go言語入門者である私が気づいたことを長々と書いています。
既に他の方が言及されていることも多いです。また初心者でよくわかっていないことも多いためお手柔らかにお願いします。

なお、順番は適当です。

Go Advent Calendar 2018 24日目の記事として投稿させていただいております。
(元々の方が投稿されていなかったようなので、代わりに入れさせて頂きました。)

継承の代わりとして匿名フィールドを用いた場合、型の判定がうまくいかない

Go言語はオブジェクト指向言語ではありませんが、構造体やレシーバを用いることでオブジェクトのメンバを「呼び出す」ことができます。
まず、Animal「クラス」を作ってみましょう。そして自己紹介するためのレシーバDescribe()も定義します。

type Animal struct {
    Age int
}
func (animal *Animal) Describe() {
    fmt.Printf("I am %v years old.\n", animal.Age)
}

こんどはPerson「クラス」を追加しましょう。人間は動物なのでPerson「クラス」ではAnimal「クラス」を継承したいですね。Go言語では匿名フィールドという機能を使えば、Person構造体にAnimal構造体の機能も持たせることができます。

type Person struct {
    Animal
    Name string
}
func (person *Person) Describe() {
    fmt.Printf("I am %v, %v years old.\n", person.Name, person.Age)
}

ついでにPlant(植物)も定義します。植物には口がないため自己紹介はできません。

type Plant struct {}

ここまでできたら、試しにAnimalとPerson, Plantのオブジェクトをそれぞれ生成して自己紹介させてみましょう。

func callDescribe(obj interface{}) {
    switch obj.(type) {
    case *Animal:
        (obj.(*Animal)).Describe()
    default:
        fmt.Println("It is not an animal.")
    }
}
func main() {
    var animal interface{} = &Animal{10}
    var person interface{} = &Person{Animal{20}, "Joe"}
    var plant  interface{} = &Plant{}
    callDescribe(animal)
    callDescribe(person)
    callDescribe(plant)
}

実行すると以下のようになります。

I am 10 years old.
It is not an animal.
It is not an animal.

動物であるはずの人間が「人間でない」と判定されてしまいました。

解決法

対象が動物かどうかを判定するときに、インターフェースを使えばうまくいきます。

type LooksLikeAnimal interface{
    Describe()
}
func callDescribe(obj interface{}) {
    switch obj.(type) {
    case LooksLikeAnimal:
        (obj.(LooksLikeAnimal)).Describe()
    default:
        fmt.Println("It is not an animal.")
    }
}

実行結果

I am 10 years old.
I am Joe, 20 years old.
It is not an animal.

期待通りに表示されました。

レシーバを使う際に注意が必要な場合

以下のコードを見てください。

type Animal struct { }

func (animal *Animal) DescribeP() {
    fmt.Println("I am a pointer of animal.")
}

func (animal Animal) Describe() {
    fmt.Println("I am animal.")
}

func main() {
    var animal Animal = Animal{}
    var panimal *Animal = &Animal{}
    animal.Describe()
    animal.DescribeP()
    panimal.Describe()
    panimal.DescribeP()
}

実行結果は以下のようになります。

I am animal.
I am a pointer of animal.
I am animal.
I am a pointer of animal.

どうやらGo言語のレシーバはポインタ型でもそうでなくても同じ働き(値渡しと参照渡しという違いはありますが)ができるようです。
(ちなみに func (animal *Animal) Describe()を追加で宣言するとmethod redeclared: Animal.Describeエラーになります)

では、main関数を以下のように書き換えて試してみます。

func main() {
    var panimal *Animal = nil
    panimal.DescribeP()
    panimal.Describe()
}

出力はこのようになります。

I am a pointer of animal.
panic: runtime error: invalid memory address or nil pointer dereference

1つ目のポインタレシーバの呼び出しはうまく行きましたが、2つ目の呼び出しは実行時エラーで失敗しました。
考えてみれば当たり前なのですが、ポインタでないレシーバを使う場合は気をつける必要があるようです。

また以下のような場合はどうでしょうか。

func main() {
    var animal Animal
    var i interface{} = animal
    animal.DescribeP()      // ok
    i.(Animal).Describe()   // ok
    i.(Animal).DescribeP()  // err: cannot take the address of i
}

今度はコンパイルが通らなくなってしまいました。これも技術的な制約であり十分理解できるのですが、やはりポインタレシーバを使う場合も気をつけなければいけないようです。

参考
https://skatsuta.github.io/2015/12/29/value-receiver-pointer-receiver/

Arrayの長さ省略表現

Go言語では配列の宣言及びコピーは以下のようにできます。

func main() {
    primes := [6]int{2, 3, 5, 7, 11, 13}
    var arr [6]int = primes
    fmt.Println(arr)
}

しかしながら、最初のprimesの宣言はすこし冗長です。例えばC言語の場合、配列の宣言と初期化を同時に行う場合は以下のように要素数を省略できます。

int primes[] = {2, 3, 5, 7, 11, 13};

Go言語で同じことをやるとどうなるでしょうか。

func main() {
    primes := []int{2, 3, 5, 7, 11, 13}
    var arr [6]int = primes
    fmt.Println(arr)
}
cannot use primes (type []int) as type [6]int in assignment

エラーになってしまいました。

解決法

Go言語で配列宣言時の要素数を省略する場合は...を用います。

func main() {
    primes := [...]int{2, 3, 5, 7, 11, 13}
    var arr [6]int = primes
    fmt.Println(arr)
}

スライス

(2019.4.15 追記)

先程の例のprimes := []int{2, 3, 5, 7, 11, 13}は配列ではなくスライスの初期化を意味します。
以下に示すように、Go言語では配列よりもスライスを活用すると便利です。

    // 長さが0のスライス
    var a = make([]int, 0)
    a = append(a, 12)

    // 最初から初期化されているスライス
    var b = []int{1, 1, 2, 3, 5}
    fmt.Println(a, b)   // 出力: [12] [1 1 2 3 5]

エラーハンドリング

Go言語にはtry...catchのようなエラー処理機構がありません。
......本当はある(panic)のですが、通常は使うことが推奨されません。(回復処理を期待できない場合のみ使う)

参考: https://dave.cheney.net/2012/01/18/why-go-gets-exceptions-right

In panicing you never assume that your caller can solve the problem. Hence panic is only used in exceptional circumstances, ones where it is not possible for your code, or anyone integrating your code to continue.

よって、エラーは基本的に全て戻り値で返します。例:

package main
import (
    "fmt"
    "os"
)

func main() {
    file, err := os.Create("test.txt")
    if err != nil {
        fmt.Fprintf(os.Stderr, "File create error\n")
    }
    defer file.Close()
}

では、さらにディレクトリを作成したくなった場合、main関数をどのように書き換えればよいでしょうか。

func main() {
    file, err := os.Create("test.txt")
    if err != nil {
        fmt.Fprintf(os.Stderr, "File create error\n")
    }
    err := os.Mkdir("hoge", 0777)
    if err != nil {
        fmt.Fprintf(os.Stderr, "File create error\n")
    }
    defer file.Close()
}

これは一見正しそうですがコンパイルエラーです。

no new variables on left side of :=

Go言語で:=は変数の宣言と代入を同時に行ってくれる演算子ですが、変数は二重に宣言することができないため、二回目にerr := ...としたところでエラーになります。よって、二回目の:==に書き換えれば良いです。

func main() {
    file, err := os.Create("test.txt")
    if err != nil {
        fmt.Fprintf(os.Stderr, "File create error\n")
    }
    err = os.Mkdir("hoge", 0777)
    if err != nil {
        fmt.Fprintf(os.Stderr, "File create error\n")
    }
    defer file.Close()
}

しかし、別の解決策もあります。実はこの例では、ファイルとディレクトリの作成順を入れ替えると:=を使用したままでもエラーが発生しなくなります。

func main() {
    err := os.Mkdir("hoge", 0777)
    if err != nil {
        fmt.Fprintf(os.Stderr, "File create error\n")
    }
    file, err := os.Create("test.txt")
    if err != nil {
        fmt.Fprintf(os.Stderr, "File create error\n")
    }
    defer file.Close()
}

Go言語の仕様上、複数の変数に対し:=で代入するとき、2つ目以降の変数については既に存在していてもエラーにならないのです。(型が合わないときはエラーになります)

上の例ではエラー処理を2回行っているのですが、少し面倒ですね。これを1回で済ます方法はないでしょうか。

func main() {
    err := os.Mkdir("hoge", 0777)
    if err == nil {
        file, err := os.Create("hoge/test.txt")
        if err == nil {
            file.WriteString("abc")
            defer file.Close()
        }
    }
    if err == nil {
        fmt.Println("Success!")
    } else {
        fmt.Fprintf(os.Stderr, "%v\n", err)
    }
}

エラー処理を最後にまとめてみました。ついでにファイルへの書き込みも行っておきました。これは一見うまく機能するように見えます。
では意図的にエラーを発生させてみましょう。4行目をfile, err := os.Create("fuga/test.txt")とすれば、書き込み先のディレクトリが存在しないためエラーになるはずです。

$ go run test.go
Success!
$ ls hoge
.  ..
$ ls huga
ls: 'huga' にアクセスできません: そのようなファイルやディレクトリはありません

エラーになりませんでした。しかしながらもちろんファイルは作成されていません。なぜでしょうか? ぜひ考えてみてください。

番外編: Go言語でtry...cacheに近いことをやる

先程Go言語にはtry...cacheが無いと書きましたが、似た仕組みはあります。それがpanicです。
では、panicを使用してどエラーを起こすにはどのようにすればよいのでしょうか。

func errFunc(name string) {
    panic(name + " does not want to do anything.")
}

func main() {
    errFunc("Bob")
    fmt.Println("Success")
}
$ go run test.go
panic: Bob does not want to do anything.

panicによってプログラムが強制終了されました。ではこれをcacheするためにmain関数を書き換えます。

func main() {
    defer func() {
        fmt.Printf("recovered from panic: ")
        fmt.Println(recover())
    }()
    errFunc("Bob")
    fmt.Println("Success")
}
$ go run test.go
recovered from panic: Bob does not want to do anything.

うまくcacheできています。ですが、"Success"が表示されません。それは、main関数全体がいわばtry...catchtry節のようになっているからです。では、finallyを実現してみましょう。

func main() {
    func(){
        defer func() {
            fmt.Printf("recovered from panic: ")
            fmt.Println(recover())
        }()
        errFunc("Bob")
    }()
    fmt.Println("Success")
}
$ go run test.go
recovered from panic: Bob does not want to do anything.
Success

一応うまくできました。しかし見た目が気持ち悪いですね。実際に使う場合はtry節の内部のみを別の関数として宣言した方がよさそうです。

for range

Go言語にも、foreachのような構文が用意されています。それがrangeです。

func main() {
    for p := range [...]int{2, 3, 5, 7, 11} {
        fmt.Println(p)
    }
}

実行結果:

$ go run test.go
0
1
2
3
4

期待した結果にはなりませんでした。正しくは以下のようにします。

func main() {
    for _, p := range [...]int{2, 3, 5, 7, 11} {
        fmt.Println(p)
    }
}

実行結果:

$ go run test.go
2
3
5
7
11

小さな違いが大きなバグを生む典型例です。

セミコロン自動挿入による文法制約

Go言語で複雑な計算をしたいとします。あまりにも複雑なため1行で収まらず、下のように途中で改行を入れました。

func main() {
    a := 2 + 3 + 5 + 7 + 11
        + 13 + 17 + 19
    fmt.Println(a)
}
$ go run test.go
# command-line-arguments
./test.go:7:16: +13 + 17 + 19 evaluated but not used

コンパイルが通りませんでした。しかし、以下のように書き換えるとうまくコンパイルできます。

func main() {
    a := 2 + 3 + 5 + 7 + 11 +
         13 + 17 + 19
    fmt.Println(a)
}

なぜこのようになるかというと、Go言語では各行末に自動的に文の終わりを示す;を挿入しているからです。2つ目の例がうまくいったのは、行の末尾が記号で終わる場合は;を挿入しない、という簡単なルールによって制御されているからです。どこかのes6とは大違いですね。(実際はそこまで単純ではないようですが)
すなわち、単純な制御構文の例でも同じことが起こります。

func main() {
    a := 2
    if a != 1    // コンパイルエラー
    {
        fmt.Println("a is not 1")
    }
}

Tclみたい

名前空間とパッケージ名

Go言語でプログラムを書くとき、最初にpackage mainと記述します。これは、「このファイルはmainパッケージに属している」という意味ですが、main以外のパッケージを作って名前空間を分けたい場合はどのようにすればよいのでしょうか。Go言語ではこのパッケージ名はディレクトリ階層に対応しています。

$ tree
.
├── main.go
└── pub
    └── sub
        └── file.go

2 directories, 2 files
$ cat pub/sub/file.go
package sub

import "fmt"

func Sub() {
        fmt.Println("Hello from sub")
}
$ cat main.go
package main

import "./pub/sub"

func main() {
        sub.Sub()
}

上の例を見てください。パッケージ名はファイル名ではなくディレクトリ名と対応していることがわかります。

interface{} が nilにならない

Go言語においてあらゆる値を代入できる方としてinterface{}があります。
そんな便利なinterface{}ですが、一度nilを代入すると大変な厄介者に...。

type A struct {}

func f() *A {
    return nil
}

func main() {
    var i interface{} = nil
    var j interface{} = f()
    if i == nil {
        fmt.Println("i is nil") // 表示される
    }
    if j == nil {
        fmt.Println("j is nil") // 表示されない (!)
    }
    if j.(*A) == nil {
        fmt.Println("j is nil") // 表示される
    }
    j = nil
    if j == nil {
        fmt.Println("j is nil") // 表示される
    }
}

Go言語では、nilは型の情報を含んでいるようです。そのため、(*A)型のnilinterface{}型のnilに変換するとおかしなことになります。

教訓

interface{}型のnilチェックでは気をつける

参考

https://qiita.com/umisama/items/e215d49138e949d7f805
https://stackoverflow.com/questions/19761393/why-does-go-have-typed-nil

プリミティブ型(chan)で変数の自動初期化を頼れない

Go言語では大概の変数は宣言すると同時に初期化されます。しかしながらchanの場合はどうでしょうか。

func main() {
    var ch chan int
    go func() {
        ch <- 123
    }()
    fmt.Println(<-ch) // fatal error: all goroutines are asleep - deadlock! と表示
}

解決法

make(chan int)を用いる

(2019.4.15追記)

chan型はnil-ableなので、宣言した直後はnilになっている、との事です。(下のコメント欄参照)

Go Modulesを使うために固有のURLを割り振る必要がある

Go1.11からGO Modulesが導入され、Go言語の標準機能でモジュールを簡単に扱えるようになりました。
Go Modulesを使用するためにはまず以下のようにgo mod initを実行する必要があります。

$ go mod init https://example.com/testproj

このプロジェクトに対してgo buildを実行すると、testprojという名前の実行ファイルが生成されます。
問題点は、公開していないプロジェクトでも、modulesを使用するためには何らかのURLを設定しなければならないことです。そういった場合どのようなURLを使えばいいのか、わかる方がいたらぜひ教えていただきたいです。

Goのツールチェーンに--verbose相当の機能がない

go言語のツールチェーンは、ビルドシステムを含んでいたり、モジュールを扱えるなど様々な機能を持っています。
ツールが様々な面倒を見てくれるのは便利である反面、おかしな挙動に出くわしたときに調べる手間が増えます。
その際、ツールの動作を調べるために--verboseオプションがあれば便利なのですが、現状用意されていません。よって、最悪Goツールチェインのソースコードまで戻って追う必要に迫られることがあります。

なぜかgotoが使える

Go言語ではgotoが使えます。(goだけに)
エラーハンドリングを一箇所にまとめるときに有用なようです。

参考: https://tmrtmhr.info/tech/why-does-golang-not-have-exceptions/

golang.org/x/text/messageでI18N

Viewing all 25 articles
Browse latest View live