LoginSignup
0
0

Golangのfor rangeでのポインタ問題をLinterで防ぐ

Last updated at Posted at 2023-08-07

for range内でのポインタの挙動

まず以下のコードを実行すると、結果はどうなるでしょうか。

main.go
package main

import "fmt"

type User struct {
	id   int
	name string
}

func main() {
	users := []User{{1, "John"}, {2, "Melissa"}, {3, "Robert"}}
	var names []*string
	for _, v := range users {
		names = append(names, &v.name)
	}
	for _, v := range names {
		fmt.Println(*v)
	}
}

結果はこうなります。すべて最後のUserの値で置き換えられてしまっています。

Robert
Robert
Robert

これはfor _, v := range文の中では、毎回同じアドレスが使われていることに起因します。

これを防ぐためには以下のように[i]でアクセスすることで対応できます。

main_fix.go
package main

import "fmt"

type User struct {
	id   int
	name string
}

func main() {
	users := []User{{1, "John"}, {2, "Melissa"}, {3, "Robert"}}
	var names []*string
	for i := range users {
		names = append(names, &(users[i]).name)
	}
	for i := range names {
		fmt.Println(*names[i])
	}
}

出力結果

John
Melissa
Robert

今回はmain.goのような書き方になってしまっているファイルをLinterで発見する仕組みを考えます。

選定

GolangでLinterといえば、golangci-lintが思いつくと思います。

カスタムルールを作成する方法も紹介されていますが、.soを書き出す必要があったりして、既存のプロジェクトに組み込むのは拡張性などの面で少しハードルがあります。

保守性と拡張性を考えた時、goastというものが使えそうです

goast

以下の開発者の記事によると、

Goのコードを読み込み、コードの抽象表現であるAST(Abstract Syntax Tree、構文抽象木)をRegoで記述されたポリシーによって評価します。ASTに関する説明はこちらの資料などがわかりやすいかと思います。 parser パッケージを使ってGoソースコードのASTを取得し、これをRegoのポリシーで評価します。評価はファイル全体のASTを一度だけ渡す、あるいはASTのノード毎に評価するモードを用意しています。

とのことです。

そのため、Regoでカスタムルールを書いていくことになります。

先に結論

プロジェクトディレクトリに.goastディレクトリを作っておきます。ここの中に.regoファイルを書いておきます。

これでfor rangeのvalueのアドレスを、append関数の中で利用しようとした場合にエラーを吐き出します。

do_not_append_for_range_value_memory_address.rego
package goast

fail[res] {
    input.Kind == "RangeStmt"
    input.Node.Value != null
    [appendPath, appendValue] := walk(input.Node.Body.List)
    appendValue[_].Fun.Name == "append" # search for append func only
    [path, value] := walk(appendValue)
    value.Op == 17 # operator: &
    [pathDetail, valueDetail] := walk(value)
    valueDetail.Name == input.Node.Value.Name

    res := {
        "msg": "do not append for range value memory address",
        "pos": value.OpPos,
        "sev": "ERROR",
    }
}

次にgoastコマンドを実行するため、インストールしておきます

go install github.com/m-mizutani/goast/cmd/goast@latest

localからコマンドラインでLinterを実行する場合は以下のコマンドを実行します。

goast eval -p .goast --ignore-auto-generated **/*.go

試しに冒頭のmain.goに対して、コマンド実行すると、以下の出力が得られます。

$ goast eval -p .goast/do_not_append_for_range_value_memory_address.rego main.go
[main.go:14] - do not append for range value memory address: "&v"

		names = append(names, &v.name)
		                      ~~~~~~~~


	Detected 1 violations

Github Actionsを利用したい場合は、goast-actionを用意していただいているため簡単に設定できます。

ci.yml
name: goast

on:
  pull_request:

jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v2
      - name: run goast Lint
        uses: m-mizutani/goast-action@main
        with:
          policy: ./.goast 
          format: text 
          source: ./
          ignore_auto_generated: true

Regoファイルの作成

まず、goastのRegoファイルにいくつかルールがあるようです。

  • package が goast でなければならない
  • 入力: input には Path、Kind のようなメタ情報と、実際のASTである Node が渡される
  • 出力:違反があった場合、 fail という変数に以下のフィールドをもつ構造体を入れる
  • msg: 違反内容のメッセージ(文字列)
  • pos: ファイル内の位置を示す整数値
  • sev: 深刻度。INFO, WARNING, もしくは ERROR

そのため基本フォーマットは以下のようになります

package goast

fail[res] {
    # ここにルールを記述

    res := {
        "msg": "エラーメッセージ",
        "pos": エラーの位置.~~Pos,
        "sev": "ERROR",
    }
}

次にルールの書きかたですが、まずはASTを出力して確認したほうが良いです。

goast dump main.go >> ast.json

出力されたast.jsonから、該当のfor文にあたるものを探します

ast.json(該当箇所以外省略)
{
  "Path": "main.go",
  "FileName": "main.go",
  "DirName": ".",
  "Node": {
    "For": 171,
    "Key": {
      "NamePos": 175,
      "Name": "_",
      "Obj": null
    },
    "Value": {
      "NamePos": 178,
      "Name": "v",
      "Obj": null
    },
    "TokPos": 180,
    "Tok": 47,
    "Range": 183,
    "X": {
      "NamePos": 189,
      "Name": "users",
      "Obj": null
    },
    "Body": {
      "Lbrace": 195,
      "List": [
        {
          "Lhs": [
            {
              "NamePos": 199,
              "Name": "names",
              "Obj": null
            }
          ],
          "TokPos": 205,
          "Tok": 42,
          "Rhs": [
            {
              "Fun": {
                "NamePos": 207,
                "Name": "append",
                "Obj": null
              },
              "Lparen": 213,
              "Args": [
                {
                  "NamePos": 214,
                  "Name": "names",
                  "Obj": null
                },
                {
                  "OpPos": 221,
                  "Op": 17,
                  "X": {
                    "X": {
                      "NamePos": 222,
                      "Name": "v",
                      "Obj": null
                    },
                    "Sel": {
                      "NamePos": 224,
                      "Name": "name",
                      "Obj": null
                    }
                  }
                }
              ],
              "Ellipsis": 0,
              "Rparen": 228
            }
          ]
        }
      ],
      "Rbrace": 231
    }
  },
  "Kind": "RangeStmt"
}

"Op": 17が、演算子(Op)「&」(17)を表しています。

次にRegoファイルの書き方です。

基本的にはASTのjsonを探索していって、すべての条件がマッチしたものだけが最後のresにたどり着いてエラー文を吐きます。

たとえば以下のルールでは、for rangeのvalueの使用自体を禁止できます。

do_not_use_for_range_value.rego
package goast

fail[res] {
    input.Kind == "RangeStmt"
    input.Node.Value != null

    res := {
        "msg": "do not use for range value",
        "pos": input.Node.Value.NamePos,
        "sev": "ERROR",
    }
}
$ goast eval -p .goast/do_not_use_for_range_value.rego main.go
[main.go:13] - do not use for range value

	for _, v := range users {
	       ~~~~~~~~~~~~~~~~~~

[main.go:16] - do not use for range value

	for _, v := range names {
	       ~~~~~~~~~~~~~~~~~~


	Detected 2 violations

複雑な探索をしたいときは、walk関数が便利です。
image.png
pathはあるkeyまでのkeyの配列、valueはそのkey以下のjsonです。

例えば、以下のように書くと


fail[res] {
    input.Kind == "RangeStmt"
    input.Node.Value != null
    [path, value] := walk(input)
    value.Op == 17
    
}

pathとvalueの値はそれぞれ

path:  ["Node","Body","List",0,"Rhs",0,"Args",1]
value: {"Op":17,"OpPos":221,"X":{"Sel":{"Name":"name","NamePos":224,"Obj":null},"X":{"Name":"v","NamePos":222,"Obj":null}}}

となります。

ちなみにdebug時にpathやvalueを確認したい場合は、json.marshal関数で文字列に変換すればresのmsgに含ませたりすることで書き出せます。

これを利用すれば、for rangeの中で&vの使用を禁止するルールがかけます。

do_not_use_for_range_value_memory_address.rego
package goast

fail[res] {
    input.Kind == "RangeStmt"
    input.Node.Value != null
    [path, value] := walk(input)
    value.Op == 17 # operator: &
    [pathDetail, valueDetail] := walk(value)
    valueDetail.Name == input.Node.Value.Name

    res := {
        "msg": "do not use for range value memory address",
        "pos": value.OpPos,
        "sev": "ERROR",
    }
}

append関数の中での&vの使用を禁止したければ、appendがあるリストと同階層から探索すればよさそうなので、以下のようになります。

do_not_append_for_range_value_memory_address.rego
package goast

fail[res] {
    input.Kind == "RangeStmt"
    input.Node.Value != null
    [appendPath, appendValue] := walk(input)
    appendValue[_].Fun.Name == "append" # search for append func only
    [path, value] := walk(appendValue)
    value.Op == 17 # operator: &
    [pathDetail, valueDetail] := walk(value)
    valueDetail.Name == input.Node.Value.Name

    res := {
        "msg": "do not append for range value memory address",
        "pos": value.OpPos,
        "sev": "ERROR",
    }
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0