ChatGPTとプログラミング言語処理
はじめに
こんにちは。ネクストビートでエンジニアをしている水島です。
2022年11月にChatGPT(GPT-3.5)が現れてからもう一年以上が経ちました。その間、色々な出来事がありました。Google、Meta、Amazonといった巨大テック企業もChatGPTのようないわゆる「生成AI」に参戦することになりましたし、日本の大手企業も生成AIのベースとなる大規模言語モデル(LLM)の開発に着手することになりました。
どの程度使っているかはさておき、ソフトウェアエンジニアをしている人でChatGPTについて聞いたこともないという人はほとんどいないと言ってもいいでしょう。とくにChatGPTは他分野に比べてもプログラミングが得意です。ChatGPTにプログラムを生成させてみたり、コードの間違いを指摘してもらったりといった経験のある方も少なくないでしょう。
さて、書籍やブログ、X(旧Twitter)など、さまざまなメディアでChatGPTの効果的な使い方についての情報が流れている現在ですが「ChatGPTにプログラムを書いてもらった」人は多くても「ChatGPTにプログラミング言語処理をさせてみた」という人はあまりいないのではないかと思います。居たら相当の変わり者でしょう。
「プログラミング言語処理」という言葉は端的に言うと、コンパイラやインタプリタといった言語処理系がやる仕事を指すときに使われます。たとえば、文字列を抽象構文木に変換する構文解析。プログラムの型があっているかをチェックする型検査。関数呼び出しを展開する(インライン展開)などの、プログラムの意味を変えずに高速化する最適化といった処理が含まれます。
今回の記事ではそんな「プログラミング言語処理」をChatGPTにやらせてみた結果について書いてみたいと思います。普段、みなさんが触れるChatGPTの「賢さ」とは違う方向での「賢さ」を感じてくだされば幸いです。
なお、実験にはWeb版のChatGPTではなくChatGPT APIのGPT-4を利用しました。モデルはgpt-4–1106-previewです。設定はtemperature=0.1に変更した以外はデフォルトのまま利用しています。
ChatGPTに構文解析をさせてみた
構文解析はプログラムの文字列を与えて、木構造(抽象構文木)に変換する処理です。これまで、構文解析器は人間が手書きするかBNFなどで書かれた文法を元に構文解析器生成系(パーザージェネレーター)に生成させるかのどちらかでした。ChatGPTはとても器用なことに、プログラミング言語のテキストを構文解析してくれます。
以下はプログラミング言語Scalaで書かれた非常に単純なプログラムを構文解析してその結果を表示するプロンプトです。
以下のScalaプログラムを構文解析していただけますか?
結果のASTをYAML形式で表示してください。また、構文エラーがある場合は
その旨と理由を日本語で表示してください。
注意:余計な解説を**一切**付け加えないでください。
```scala
val x = 1 + 1
println(x)
```
このプロンプトに対する返答は(皆さんご存知のように、実行のたびに若干変わりますが)以下のようになります:
- Type: ValDef
Modifier: 'val'
Name: x
RHS:
Type: Apply
Function:
Type: Select
Qualifier:
Type: Literal
Value: 1
Name: +
Arguments:
- Type: Literal
Value: 1
- Type: Apply
Function:
Type: Ident
Name: println
Arguments:
- Type: Ident
Name: x
筆者が目視確認した限り、このプログラムに対しては誤りのない構文解析ができています。
次に以下のような構文に誤りのあるプログラムを与えてみます:
val x == 1
結果は次のようになります:
構文エラー: Scalaの変数宣言には '==' は使用できません。代わりに単一の '=' を使用する必要があります。
修正すべきコード:
```scala
val x = 1
```
AST:
```yaml
- Type: ValDef
Mods:
- Type: Mod.Val
Pos: (0,0)-(0,3)
Name:
Type: Term.Name
Pos: (0,4)-(0,5)
Value: "x"
Decltpe: None
Rhs:
Type: Lit.Int
Pos: (0,8)-(0,9)
Value: 1
```
理由だけでなく修正候補まで提示してくれました。
これはよく考えなくてもすごいことです。構文解析は言語処理系の内部的な処理として行われるため、LLMがインターネット上のプログラムからScalaプログラムについて学習しようにも、Scalaプログラムと抽象構文木がどのように対応しているかのドキュメントはありません。一番近いと言えるのはScala公式の言語仕様(Scala Language Specification)くらいですが、Scalaプログラムをどのように抽象構文木に対応付けるかについてはきちんとした記述はありません。
このような処理は「ただ多くの学習済みテキストから「それっぽい」文字列を出力する」だけで実現できるとは俄には信じがたいことですが、GPT-4が実際にできてしまっていることも確かです。AIについては門外漢である筆者の感想にはなりますが、GPT-4は「構文解析」という概念について、人間の「理解」と同じでないにせよ近い「何か」を持っているように見えます。
ChatGPTに型検査をさせてみた
さて、次に型検査をさせてみることにします。さきほどの例と同様に簡単なScalaプログラムを与えて、型検査の結果を表示させるプロンプトを与えます:
以下のScalaプログラムを型検査していただけますか?
型検査の結果、成功した場合はその旨を、失敗した場合は失敗した理由を
日本語で表示してください。
```scala
val x = 1
println(x * 2)
```
結果は次のようになります:
Scalaプログラムの型検査ですが、提供されたコード:
```scala
val x = 1
println(x * 2)
```
は正しいScalaの構文であり、型検査を成功するでしょう。以下がその理由です:
1. `val x = 1` は整数リテラル`1`を持つ不変(immutable)変数`x`を宣言します。Scalaでは、整数リテラルはデフォルトで`Int`型です。
2. `println(x * 2)` では、`x`変数の値に`2`を掛けています。`x`は`Int`型で、`2`も`Int`型です。`Int`型同士の乗算演算は`Int`を返すので、型検査は成功します。
3. `println`関数は`Any`型の引数を受け入れることができ、任意の値を標準出力に印刷するため、`Int`型を受け入れることができます。
したがって、このScalaプログラムは型検査に成功し、実行結果として`2`を掛けた`x`の値、つまり`2`を掛けた`1`、すなわち`2`が標準出力に印刷されます。
構文解析の結果からある程度予想できたことですが、Scalaプログラムの「型検査っぽいこと」をして、なぜ型検査が成功するかを表示できています。
以下のように型検査が失敗するプログラムを与えた場合はどうでしょうか?
val x = "Hello, World!"
println(x - 2)
文字列から整数2を引こうとしているため、(当然のことながら)Scalaのコンパイラはこのプログラムの型検査に失敗しますが、ChatGPTは以下の結果を返しました:
このScalaプログラムの型検査は失敗します。その理由は、Scalaにおいて`String`型の変数から整数を引く(`-`)という演算子はサポートされていないからです。`println(x - 2)` という行が問題となります。
`"Hello, World!"`は`String`型を持ちますが、`-`演算子は`String`型に対して定義されておらず、`Int`型の値(例えば、`2`)を引こうとすると型エラーになります。`String`に整数を加算する場合、通常、その整数を文字列に変換し、`+`演算子を使用して文字列の結合を行います。
型検査が失敗したため、このプログラムはコンパイルできず、実行することはできません。
修正するには、例えば、整数を引くのではなく文字列に何かを追加する、または文字列から特定の文字を除去するなどの操作に変更する必要があります。`-`演算子を使用したい場合は、数値型に対してのみ使用できることに注意してください。例えば、次のようになります。
```scala
val x = "Hello, World!"
// println(x - 2) // 型検査エラーになる行を削除
println(x) // 単に文字列を出力する
```
もしくは、特定の機能を実装している場合、具体的な操作を行う必要があるかもしれません。例えば、文字列の後ろから2文字を切り取るには、次のように書けます:
```scala
val x = "Hello, World!"
println(x.dropRight(2)) // "Hello, Worl"と出力される
```
これは型検査に成功し、期待される結果を出力します。
String型が-演算子を持っていないから型検査に失敗するという理由説明だけでなく、型検査を成功させるための修正候補を提示してくれています。
Scalaに限らず多くの静的型付き言語の型検査器はエラーになった「理由」までは一般に説明してくれないことを考えると、やはりChatGPTは非常に「賢い」と言えるでしょう。
ChatGPTにインライン展開させてみた
コンパイラによるプログラムの最適化の1つに「インライン展開」と呼ばれるものがあります。これはプログラム内部で関数呼び出しがあった場合に、関数呼び出しをその本体に置き換える操作です。
たとえば、次のScalaプログラム
def add(x: Int, y:Int) :Int = x + y
println(add(2, 3))
においてadd(2, 3)をインライン展開すると次のようになります:
def add(x: Int, y:Int) :Int = x + y
println(2 + 3)
インライン展開をプログラマーが手動でやる必要は滅多にありませんが、現代的なプログラミング言語処理系では、関数呼び出しのインライン展開をある程度自動でやってくれます。関数呼び出しは比較的実行コストが大きい操作のため、最適化の中でもインライン展開は多くのプログラミング言語処理系が備えている機能になります。
さて、それではChatGPTにインライン展開をやってもらいましょう。プロンプトは以下のようになります(このScalaプログラムが実行された場合、120が表示されるはずです):
以下のScalaプログラムを解析して、関数呼び出しがある場合は
可能な限りインライン化していただけますか?
インライン化したScalaプログラムを表示してください。
```scala
def factorial(n: Int): Int = n match {
case 0 => 1
case _ => n * factorial(n - 1)
}
}
println(factorial(5))
```
結果は以下のようになります:
問題のScalaプログラムは再帰関数`factorial`を定義し、5の階乗を計算して出力するものです。関数をインライン化するとは、関数の呼び出しをその関数の本体で置き換えることです。この再帰呼び出しをインライン化するプロセスを繰り返すと、下記のような手続きになります。以下はそのインライン化のプロセスです:
```scala
println(factorial(5))
// 上の factorial(5) をインライン化すると以下のようになる
println(5 * factorial(4))
// 更に factorial(4) をインライン化する
println(5 * (4 * factorial(3)))
// 更に factorial(3) をインライン化する
println(5 * (4 * (3 * factorial(2))))
// 更に factorial(2) をインライン化する
println(5 * (4 * (3 * (2 * factorial(1)))))
// 更に factorial(1) をインライン化する
println(5 * (4 * (3 * (2 * (1 * factorial(0))))))
// 最後に factorial(0) をインライン化する
// factorial(0) の結果は 1 であることは上記の関数の定義より明らか
println(5 * (4 * (3 * (2 * (1 * 1)))))
// 最終的な結果
println(5 * 4 * 3 * 2 * 1)
// 計算結果は 120
println(120)
```
最終的なScalaプログラムは、以下のようになります(インライン化完了版):
```scala
println(120)
```
このプログラムは直接結果を表示するだけなので、`factorial`関数の定義自体はもはや必要ありません。もし他の場所で`factorial`を使用する必要があれば、関数定義をプログラムに含めるべきですが、単純な出力のためには上記のように結果を直接表示することができます。
なんと、factorial(5)の呼び出しをインライン展開して5 * 4 * 3 * 2 * 1にしただけではなく、その結果の計算までやってくれています。
プログラム中に現れる5 * 4 * 3 * 2 * 1のような式を事前に計算して120のように置き換えてしまう最適化は定数畳み込みと呼びますが、とくに指示していないのに気を利かせて(?)やってくれたようです。
ChatGPTはある意味で「賢すぎる」ため、このように指示していないことについても気を利かせて勝手にやってくれる場合がありますが、時にそれでは都合が悪いこともあります。このケースでは定数畳み込みまでやって欲しいかは微妙なこともあるでしょう。
そのような動作を抑止するにはそのための文言を付け加える必要があります。たとえば、さきほどのプロンプトを修正したものを与えます:
以下のScalaプログラムを解析して、関数呼び出しがある場合は
可能な限りインライン展開していただけますか?インライン化以外の
最適化、たとえば定数畳み込みなどは行わないでください。
インライン化したScalaプログラムを表示してください。
```scala
def factorial(n: Int): Int = n match {
case 0 => 1
case _ => n * factorial(n - 1)
}
}
println(factorial(5))
```
結果は次のようになります:
Scalaプログラムにおけるインライン展開は、関数呼び出しをその関数の本体で置き換えることを意味します。ただし、再帰関数であるfactorialの完全なインライン展開は、無限に多くの置き換えが発生するため不可能です。しかし、具体的な値(この場合は5)に対するインライン展開は可能です。
以下は、`factorial(5)`を手動でインライン展開した例です。このプロセスは関数の実行に沿って各ステップを置き換えて行きますが、あくまで教育的な例示を意図しており、Scalaコンパイラがコードをどのように扱うかとは異なる場合があります。
```scala
def factorial(n: Int): Int = n match {
case 0 => 1
case _ => n * factorial(n - 1)
}
println(factorial(5))
// ↑ factorial(5) をインライン展開すると
// n match {
// case 0 => 1
// case _ => n * factorial(n - 1)
// }
// 以下、n=5 のケースを使用
println(5 * factorial(4))
// ↑ factorial(4) をインライン展開
println(5 * (4 * factorial(3)))
// ↑ factorial(3) をインライン展開
println(5 * (4 * (3 * factorial(2))))
// ↑ factorial(2) をインライン展開
println(5 * (4 * (3 * (2 * factorial(1)))))
// ↑ factorial(1) をインライン展開
println(5 * (4 * (3 * (2 * (1 * factorial(0))))))
// ↑ factorial(0) をインライン展開
println(5 * (4 * (3 * (2 * (1 * 1)))))
```
最後の行は、`factorial(0)`をインライン展開して1を返しており、これ以上インライン展開する関数呼び出しはありません。このコードは`factorial`関数の具体的な引数に対してインライン展開を行なった結果であり、実際の再帰的な処理を反映していますが、Scalaの最適化処理と同じとは限りません。
より実用的なアプローチとしては、末尾再帰最適化などを利用することで再帰関数がループに変換されることが考えられますが、質問の指示に従い、上記のような手動でのインライン展開を行いました。
インライン展開のされ方については呼び出し毎に微妙に変わるため、常に意図通りの結果が返ってくるわけではないことに注意する必要がありますが、定数畳み込みは抑止されているのがわかります。
注意事項
これまでChatGPTがプログラミング言語処理という分野でもいかに賢いかを見てきましたが、ChatGPTを普段使っている方ならご存知のように、必ず同じ結果を返すわけではありません。
たとえば、さきほどのインライン展開についてですが、以下のように「インライン展開としては正しい」ものの凄く冗長なScalaコードが出力されることもありました:
```scala
println({
val n = 5
n match {
case 0 => 1
case _ =>
n * ({
val n = 4
n match {
case 0 => 1
case _ =>
n * ({
val n = 3
n match {
case 0 => 1
case _ =>
n * ({
val n = 2
n match {
case 0 => 1
case _ =>
n * ({
val n = 1
n match {
case 0 => 1
case _ =>
n * factorial(n - 1) // ここで再帰呼び出しが残ります
}
})
}
})
}
})
}
})
}
})
```
このような意図しない出力をなるべく防ぐためにはプロンプトの文言を工夫して特定の方向に誘導する必要があります。この場合だと「結果のコードがなるべく簡潔になるようにしてください」などの指示がありえます。
プログラミング言語処理に限らず、ChatGPTでは望ましい結果を得るためのプロンプト調整(プロンプトエンジニアリング)が非常に重要になってくることを念頭においてください。
まとめ
この記事ではプログラミング言語処理、とくに
- 構文解析
- 型検査
- 最適化(インライン展開)
をChatGPTにさせてみました。結果を見る限り、ChatGPTの「Scala理解度」はかなりのものであると言えそうです。また、私がこれまでに試行錯誤した限りでは、それなりにメジャーな言語のほとんどにおいて「プログラミング言語理解」はかなりの精度に達しています。
しかし、非常に勿体ないことに、ChatGPTを使ってプログラム生成をする例は多いものの、今回の記事のようにプログラミング言語処理をさせてみた例は意外に多くないように思えます。
ChatGPTの自然言語理解の精度はもちろん驚くべきものですが、筆者にしてみるとそれ以上にプログラミング言語などのいわゆる「非自然言語」理解度は驚くべきものです。この記事をきっかけに「プログラミング言語処理」をChatGPTにさせてみる方が増えれば嬉しいです。
We are hiring!
本記事をご覧いただき、ネクストビートの技術や組織についてもっと話を聞いてみたいと思われた方、カジュアルにお話しませんか?
・今後のキャリアについて悩んでいる
・記事だけでなく、より詳しい内容について知りたい
・実際に働いている人の声を聴いてみたい
など、まだ転職を決められていない方でも、ネクストビートに少しでもご興味をお持ちいただけましたら、ぜひカジュアルにお話しましょう!
🔽申し込みはこちら
https://hrmos.co/pages/nextbeat/jobs/1000008
また、ネクストビートについてはこちらもご覧ください。