ChatGPTで自然言語から作ったシェルスクリプトがバグってた話

## はじめに

もちろんですがこの話はシェルスクリプトに限った話ではありません。プログラミング言語というか ChatGPT が返す答え全てに当てはまる話です。ChatGPT が生成したコードが正しく動くとは考えないでください。ChatGPT を使う上での心構えを例えるなら「先生にテストの正答を教えてもらった」ではなく「（優秀な？）生徒がテストに回答した」と考えることです。どれだけ優秀な生徒の回答だからといって確認もせずに 100 点満点にしないですよね？ 回答が正しいことは確認しなければいけません。そのことを例を持って示す良い機会が見つかったので、そのことを記事にしました。

**注意** 今日発表されたらしい GPT-4 ではありません

## 現実に起きた例

このような例を私が考えると「都合のいい例を見つけただけだろ」となって説得力がでないので、あえて他の人が書いた記事を例に使っています。ChatGPT（人工知能）の一般論であって ChatGPT が使い物にならないとか、@hirokidaichi さんが開発した `wanna` コマンドが使い物にならないという話ではありません。

https://qiita.com/hirokidaichi/items/6d568d5ddcb02a91edf4

こちらの記事によると

> 「このディレクトリの配下にあるすべての.pyファイルを再帰的に発見して、それらの行数の合計を出力する」

という質問から以下のようなシェルスクリプトが生成されたとあります。

```bash:example.sh
#!/usr/bin/env bash
find . -name "*.py" | xargs wc -l | awk '{total += $1} END{print total}'
```

よーく見てください。

${\large 間違っていますね}$

実際に正しく動いていないことを確認してみましょう。まず Python ファイルを作ります。内容に意味はありませんが計算しやすいように 10 行にしています。

```python
#!/usr/bin/env python3

# main function
def main():
    # print message
    print('Hello World!')

if __name__ == '__main__':
    main()
    exit()
```

行数を確認してみましょう。確かに 10 行ですね。

```console
$ wc -l hello.py
10
```

これをコピーします。

```console
$ cp hello.py hello1.py

$ ls -al
合計 84
drwxrwxr-x  2 koichi koichi  4096  3月  6 20:55 .
drwxrwxrwt 28 root   root   69632  3月  6 20:39 ..
-rwxrwxr-x  1 koichi koichi    93  3月  6 20:45 example.sh
-rwxrwxr-x  1 koichi koichi   148  3月  6 20:50 hello.py
-rwxrwxr-x  1 koichi koichi   148  3月  6 20:50 hello1.py
```

では実行してみましょう。10 行のファイルが ２ つですから、合計は 20 行になるはずです。

```console
$ ./example.sh
40
```

${\large ほらね、間違ってますよね}$

## ChatGPT は何を間違えたのか？

ChatGPT が生成した

```sh
find . -name "*.py" | xargs wc -l | awk '{total += $1} END{print total}'
```

の `| awk '{total += $1} END{print total}'` を消して実行してみれば理由はすぐに分かります。

```console
$ find . -name "*.py" | xargs wc -l
 10 ./hello1.py
 10 ./hello.py
 20 合計
```

`wc` コマンドに**複数のファイルを渡した場合**、最終行にその合計を出力します。これは [POSIX](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/wc.html) でも標準化されている仕様です。

ちなみに私が `wanna` コマンドで質問した場合以下のような回答が得られました。

```sh
find . -name "*.py" | xargs wc -l | awk '/total/ {print $1}'
```

`total` の行を取得しているので改善されているように思えますが、

1. `total` は日本語ロケールでは「合計」と出力されるので `LANG=C` などを付ける必要がある
2. ファイルが一つしかない場合、合計行は出力されない
3. `total` という文字列が含まれたファイルがあると誤動作する

　という問題があるので、これもまた正しい答えではありません。

## 人工知能が間違っていても責任を取ってくれない

~~ぱっと調べた限りこのコードの間違いを指摘している人はまだいないようです。出力されたコードの例までちゃんと見る人はあまりいないということなのでしょう。~~ この記事を書き始めた時点ではいなかったのですが、だらだら書いている間に元記事のコメントで指摘されていました。

教訓はありきたりですが人工知能が生成したコードが正しいのかちゃんと調べることです。**ChatGPT がメチャクチャな回答をすることがあるのは、すでに周知の事実になっている**と思います。個人的にはコードを自分で書けない人や意味がわからない人は人工知能が出力したコードを利用するべきではないと思います。「検索して Stack Overflow などで見つけたコードを理解せずにコピペするな」とはよく言われますが、「人工知能で生成したコードを理解せずにコピペするな」という言葉を広めないといけないようです。

ちなみにブラウザ版の ChatGPT で（「シェルスクリプトで答えて」を追加して）同じ質問をしたら正しい答えが返ってきました。精度は上がっているようです。

## おまけ ChatGPT君はコードの意味を理解していない

**注意** 質問は過去の会話から推測されないように、すべて新しいセッションを作ってから実行しています。

### 期待したコードを生成してくれるか？

私は訳あって外部コマンドを使わずにシェルスクリプトの言語機能だけを使ったシェルプログラミングをしています。例えば次のような関数を書いています。`replace_all` という関数名から判断出来ると思いますが文字列の置換処理を行う関数で、文字列の中から指定した文字列をすべて別の文字列に置換する関数です。以下は bash などの拡張機能を使った場合のコードです。

```sh
#!/bin/bash

replace_all() {
  eval "$1=\${2//\"\$3\"/\"\$4\"}"
  # 以下のコードを実行している
  # ret=${ret//"bar"/"BAR"}
}

replace_all ret "foobarbaz" "bar" "BAR"
echo "$ret" # => fooBARbaz
```

上記のコードは純粋な POSIX シェルでは動作しないので、それを POSIX で標準化されている機能だけを使って書くと以下のようになります。関数の中身が読みづらいのは POSIX ではローカル変数 (`local`) が使えず、グローバル変数も使いたくないからです。

```sh
#!/bin/sh

replace_all() {
  set -- "$1" "$2$3" "$3" "$4" ""
  while [ "$2" ]; do
    set -- "$1" "${2#*"$3"}" "$3" "$4" "$5${2%%"$3"*}$4"
  done
  eval "$1=\${5%\"\$4\"}"
}

replace_all ret "foobarbaz" "bar" "BAR"
echo "$ret" # => fooBARbaz
```

この関数を ChatGPT が生成できるか？と考え、いろいろと言葉を変えて試してみたのですが結果的に求める動作を行うコードを生成させることはできませんでした。例えば文章は以下のような感じです。

> POSIXで標準化されているシェル言語の機能のみを使って置換関数を作成して下さい。引数は、第二引数が元の文字文字列、第三引数が置換したい文字列、第四引数が置換後の文字列。戻り値は第一引数で指定した変数に戻して下さい。

実行するたびに違う答えを返してくるのですが、何回かやったらそれっぽいコードは生成されました。

```sh
replace() {
  local str="$2"
  local search="$3"
  local replace="$4"
  while [ "${str#*$search}" != "$str" ]; do
    local head="${str%%$search*}"
    local tail="${str#*$search}"
    str="${head}${replace}${tail}"
  done
  eval "$1"="'$str'"
}
```

上記のコードには以下の問題があります。

1. `local` は POSIX で標準化されていないので動かないシェルがある
2. 置換後の文字列に置換前の文字列が含まれてると無限ループする
   - 例 `replace ret "foobarbaz" "bar" "barbar"`
3. シェルのメタ文字が含まれているときに動作しない
   - 例 `replace ret "foo*baz" "*" "BAR"`
   - 正しくはクォートが必要
     - `local head="${str%%"$search"*}"`

人間でもこのような（完璧ではない）答えを返すだろうというレベルの回答で素晴らしいといえます。しかし完璧には動作していません。ここに問題点を解決すべく追加で条件をつけると、意味のない冗長なコードを生成したり外部コマンドを使いだしたりとルール破りをしてきました。

### コードの意味を理解できるか？

次に逆にコードを入力してその意味を理解できるかを検証してみました。「以下のシェルスクリプトの意味を教えてください。」の下に先程の `replace_all` 関数を貼り付けて聞いた所、長いので最後のまとめ部分だけを引用しますが、意図を理解しているかのような素晴らしい回答を返してくれました。

> このスクリプトは、正規表現を使用して文字列を置換する代わりに、シェルの組み込み関数を使用して実現されます。これは、正規表現を扱うのが複雑であったり、置換処理のパフォーマンスが重要である場合に有用です。ただし、このスクリプトは、文字列内の置換対象の文字列が重複している場合には正しく機能しません。

ただ「置換処理のパフォーマンスが重要である場合」は誤解を与えるもので、外部コマンドを呼び出さないので文字列が一行程度の小さな文字列の場合にはパフォーマンスが良いのですが、長い文字列の場合には外部コマンド (`sed`) を使ったほうが速いです。「文字列内の置換対象の文字列が重複している場合には正しく機能しません」は（何を意味しているのかよくわかりませんが）間違いですね。この関数はどのような文字列でも正しく機能します。コードの詳細は説明はかねがね正しいように思えます。では ChatGPT はコードの意味を理解しているのでしょうか？ ここで意地悪で関数名を `replace_all` から `repeat` に変更して聞いてみました。

${\large そしたら見事に騙されてくれました}$

> この関数は、4つの引数を受け取ります。最初の引数 `$1` は、繰り返す文字列です。2番目の引数 `$2` は、文字列を連結する区切り文字列です。3番目の引数 `$3` は、繰り返し回数を表す整数値です。4番目の引数 `$4` は、繰り返し処理を行うブロックとして扱われるコマンドです。

面白いことにコードのそれぞれの行の意味は正しいように思えます。

> 1. 引数を `set` コマンドで変数に格納する。
> 2. `while` ループで繰り返し処理を行う。ループは、`$2` が空文字列になるまで繰り返される。
> 3. `$2` の先頭から、最初に出現する `$3` までの文字列を `${2%%"$3"*}` で取り出し、それを `$4` で指定されたコマンドと結合して変数に格納する。
> 4. `$2` の先頭から最初に出現する `$3` までの文字列を `${2%%"$3"*}` で取り出した部分を `$5` に追加する。
> 5. `eval` コマンドで `$1` に、`$5` の末尾に追加された `$4` を削除した値を代入する。

しかし、ここまでコードの意味を説明できておきながら引数の意味はでたらめです。つまり関数名が `repeat` だから `repeat` 処理をしていて、引数の意味はそれっぽいやつだろと判断しており、コードの意味を理解して推論して関数名が間違っているという結論を導き出すことはできていないということです。もちろん同じ問題を人間に出したとしても「関数名が `repeat` だから `repeat` 処理してるんだろ」と騙される人はいるでしょう。人工知能としては十分高い精度だとは思いますが、やはり人が間違えるようなものは人工知能も間違えるということです。人工知能の答えが正しいのかの検証はしなければいけません。

### ChatGPTがコードを実行するわけないじゃないか

以下の記事を読むといかにもChatGPTが仮想マシンでコードを実行しているかのような印象を受けるので、実際にコードを実行しているのか調べてみました。

[チャットAI「ChatGPT」内部に仮想マシンを作成する試み、内部には仮想インターネットが存在しChatGPTが創造した世界にもChatGPTが存在](https://gigazine.net/news/20221206-virtual-machine-inside-chatgpt/)

まず元記事にあった Linux ターミナルの**振りをしてくれ**と要求します。

> I want you to act as a Linux terminal.I will type commands and you will reply with what the terminal should show. I want you to only reply with the terminal output inside one unique code block, and nothing else.Do not write explanations. Do not type commands unless I instruct you to do so. When I need to tell you something in English I will do so by putting text inside curly brackets {like this}. I am the user root, my first command is pwd.

実にそれっぽい動作をしていますね。

```console
[root@localhost ~]# pwd
/root
```

そして `replace_all` 関数を関数名から挙動を推測できないようにデタラメにして実行を依頼します。引数も推測できないように紛らわしいものにしています。

```sh
asdkjfastjuaodfsjo() {
  set -- "$1" "$2$3" "$3" "$4" ""
  while [ "$2" ]; do
    set -- "$1" "${2#*"$3"}" "$3" "$4" "$5${2%%"$3"*}$4"
  done
  eval "$1=\${5%\"\$4\"}"
}

asdkjfastjuaodfsjo ret "123123123" 2 5
echo "$ret"
```

出力は次のとおりです。

```console
[root@localhost ~]# asdkjfastjuaodfsjo ret "123123123" 2 5
[root@localhost ~]# echo "$ret"
31231
```

実行した風な出力ですが、本当に実行しているならば意味を理解していなくとも正しい答えを返せるはずなので、実際にはコードを実行していないことがわかります。コードをぱっとみて「きっとこうに違いない」という思い込みを答えているような挙動ですね。

### 結論

もちろん、この例では問題が意地悪だと思います。一般的にシェルの言語機能だけを使って置換関数なんて作りませんし、ましてやローカル変数もグローバル変数も使わないなどということはしません。しかし実現可能なことが ChatGPT には作れなかったということは事実です。

`replace_all` の実装は私が何回か記事にしたりコードをアップしたりしていますが、比較的最近なのでまだ ChatGPT のデータには含まれていないのかもしれません。もしかしたらいずれ生成できるようになるかもしれませんが、そしたら私のコードをパクってるのことになるかもしれませんね。まあライセンスは CC0 ですし広めてもらいたいので別にかまわないのですが、場合によってはライセンス違反などの問題につながるかもしれません。

結論としては ChatGPT は検索で見つかるような情報を見つけてくれるが新しいものを思いつくことはできず、それっぽい答えを返しているだけで、的中率は高いが別に考えているわけではない、という当然の結論です。もちろん ChatGPT が使い物にならないという話ではありません。ChatGPT は正しい答えを見つけてくれるものではなく、こういう挙動をしているものなので正しく使いましょうということです。