🤖

ChatGPTとGitHub Copilotで始めるプログラミングフロー

2023/07/09に公開

ChatGPTやGitHub Copilotと言ったプログラミングを支援する環境がより強力になりましたね!
最近はもうこの2つがないとプログラミングできないなと思うぐらい使い込んでいて、使い方も定まってきました。
そこで、僕が最近やっている使い方を紹介したいと思います。

お題

お題として相互変換可能な長さを表すValue ObjectをRustで作ってみましょう。
つまり作成するのはm <-> cm <-> mmと変換可能で、それぞれを比較可能な構造体ですね。

前提を詰める

みなさんは長さの単位として何があるかご存知ですか?僕も全ては知りません。
ChatGPTさんに聞いてみましょう。

長さの単位について教えてください。cmから相互変換可能な長さの単位を列挙してどのようなときに使われるのか教えてください。
以下に、cm(センチメートル)から相互変換可能な一部の長さの単位を挙げて、それぞれの用途について説明します。

1. ミリメートル(mm): 1cm = 10mm
   これは非常に小さい長さを測るために使われます。例えば、ネジの直径や、細かい部品の大きさを測るときによく使用されます。
2. インチ(in): 1in = 2.54cm
   インチは主に英米系の国で使用されています。テレビやパソコンのモニターの大きさ、自転車のタイヤの直径、写真のサイズ等を表すのによく使用されます。
3. フィート(ft): 1ft = 30.48cm
   フィートもまた英米系の国で使われます。身長や建物の高さなどを示すのによく使用されます。
4. メートル(m): 1m = 100cm
   メートルは国際単位系(SI)における基本単位で、科学的な計測や日常的な距離測定(部屋の大きさやスポーツフィールドの長さ等)に広く使用されます。
5. キロメートル(km): 1km = 100,000cm
   キロメートルは長距離を表すために使用されます。例えば、地理的な距離(都市間の距離など)や競技での走行距離(マラソンなど)を示すのに使われます。
6. マイル(mi): 1mi = 160,934.4cm
   マイルは主に英米系の国で長距離を表すのに使用されます。道路の距離や飛行機の飛行距離等を示すのに使われます。

以上のような単位がありますが、用途は一部の例を挙げただけで、より多くの場面でそれぞれが使用されています。そして、これらは全て相互に変換が可能です。

聞く前はCm(i64)みたいなの作ればいいかなと思っていましたが、説明を見る限りCm(f64)など浮動小数で持つ方が良さそうですね。

こういった自分が知らないことを知るにはインターネット検索も並行して行うことは重要です。
LLMではハルシネーションが起きるため回答が事実に基づかない回答になることがあります。質問の仕方によってハルシネーションを軽減できることはたしかですが、自分がそれを知らないとそういった質問をすることはとても難しいです。

そのため「長さ 単位」などインターネットで検索を行い、出てきたページを読む、または公式に定義されたものがあればそちらを見に行くなどはとても重要です。

このあたりはどちらか出来れば良いというより両方を相互に行うことで、より高速に、より深く知識のインプットを行えることが重要です。

ある程度、方向性が見えたので今回は説明を簡単にするためにミリメートル、センチ、メートルに対応しようと思います。

※ 今回は一般常識に近いのでそこまで1回の質問で終えてますが、必要な情報が集まらなければ何度もやりとりしますし、新しい疑問がわけば新しく質問を行なったりもします

コードを作成する

初回のコードの作成は手で作成することもあるし、ChatGPTを使って生成することもあります。
このあたりは脳内にコードができていれば書いた方が早いですし、ゆるいイメージしかないときは叩き台としてChatGPTでコードを生成することもあります。

今回は例示ということでChatGPTを使ってコードを生成してみましょう。

Rustで長さの単位を表す相互変換、相互に比較可能なミリメートル、センチメートル、メートル型を作成しただけますか?
#[derive(Debug, PartialEq, PartialOrd, Clone, Copy)]
struct Millimeter(f64);

#[derive(Debug, PartialEq, PartialOrd, Clone, Copy)]
struct Centimeter(f64);

#[derive(Debug, PartialEq, PartialOrd, Clone, Copy)]
struct Meter(f64);

impl Millimeter {
    fn to_centimeter(self) -> Centimeter {
        Centimeter(self.0 / 10.0)
    }

    fn to_meter(self) -> Meter {
        Meter(self.0 / 1000.0)
    }
}

impl Centimeter {
    fn to_millimeter(self) -> Millimeter {
        Millimeter(self.0 * 10.0)
    }

    fn to_meter(self) -> Meter {
        Meter(self.0 / 100.0)
    }
}

impl Meter {
    fn to_millimeter(self) -> Millimeter {
        Millimeter(self.0 * 1000.0)
    }

    fn to_centimeter(self) -> Centimeter {
        Centimeter(self.0 * 100.0)
    }
}

ChatGPTが生成したコードは上記になります。

このコードをどのようにより洗練させるかは対象に対する知識の有無でわかれます。

  1. 対象の知識がない場合
    a. 対象の違和感を言語化してより知識を深めながら洗練させる
    b. ChatGPT自身にコードを批判させ洗練させる
  2. 対象の知識がある場合
    a. 問題点を具体的に指摘して洗練する
    b. 自分で書き直す

AI脳で言えば1-bに期待したいところですが、2023/07時点では期待した結果に収束しないことが多いため、あまり活用はしなくなりました。
だいたいChatGPTでコードを生成する場合、1-a、2-aで行っていきます。

対象の違和感を言語化して洗練させる

ここで必要になるのは自分でいろいろ調べ、対象の違和感を深掘りしていくことです。
もちろん違和感があるのですが・・・と相談することでとっかかりを得るのも間違いではないです。が、ChatGPTは基本的に自身の発言は正しいという前提で話すため、この方法では問題の解決には繋がりにくいということが多いです。

これを踏まえ先のコードから継続していくつか質問をしてみましょう。

Rustの命名規則としてCentimeterとCentiMeterではどちらが正しいのでしょうか?
英語がよくわかっていないのですがCentiとMeterは別単語ではなく、一単語になるのでしょうか?
Rustではこういった変換はFromを使うと思っていたのですが、Fromを使う使わないの使い分けはどこで行われているのでしょうか?

わからないことはどんどん聞いて、知識を深めてどのようなコードにしたいかを明確にしていきます。
人に聞くのと違い、即時にレスポンスが返り、相手の時間を奪わない、相手に聞くことでどう思われるか考えないで済むのはLLMを使う上での利点ですね。
ただし、前述の通りハルシネーションがあるので裏取りとして、各種ドキュメントや、他のコードを読むことはとても大事です。

質問の仕方ですが、生成したコードに対して質問したい場合は編集を使って質問するのが良いです。

ChatGPTの特性として会話が長くなると過去のやりとりを忘れる、他の質問がノイズになり回答がそちらに引きずられるというものがあるため、コード生成直後のメッセージでやり取りをするとこういった問題に遭遇しにくくなります。

また、ハルシネーション対策として新しく質問を作り直すというのも重要です。
前述の通りChatGPTの特性として前の会話に引きずられるため、新しく質問を行い同じ回答するかというのを見ることはよくやります。

問題点を具体的に指摘して洗練する

具体的に問題点がわかっているなら直接指摘を行うと修正が早いです。

to_millimeterなどの専用のメソッドを用意するのではなく、Fromトレイトを使うように変更してください。
各単位間で相互に比較できるようにしてください。

ここで大切なのはあれもこれも指摘するのではなく一つずつ直していくことです。
例えばモブプログラミングするときに一度に全ての指示はしませんよね?それと同じで一つずつ指示を出し確実に洗練していかなければなりません。
特にChatGPTは特性として長い指示には荒い回答をする傾向があります。そのため全てを一度に指示しても全ての修正が行われず、何度も同じ指摘を繰り返すことになりフラストレーションが溜まります。自身のメンタルヘルスのためにも一つずつやりましょう。

自分で書き直す

だいたい、書くべきコードがわかっているなら自分で書いた方が早いです。
このときGitHub Copilotを使うとコードをさくさく作ることができます。



上記はCopilotで3パターンほど入力を変えて、補完内容の変更をしています。
これは期待するコードになっていればどこで補完しても構いません。上手くハマるケースでは1文字も書かずに補完しきれることもあります。

こういったFromというようなボイラープレートコードは上手く書きやすいです。

もし期待するコードにならないときは下記を意識してみてください

  • シグネチャを書く
  • シグネチャにコメントをつけ期待する振る舞いを記述する
  • 例として1つ目は自力で書く
  • どうにもならない時はコメントで指示を書く
    • Instruction: XXXをYYYしてください

余談ですが、Rustの場合ではmapを使うような型変換とポインタを使った型変換はあまりコンパイルできないコードを出力できないことが多いです。そういったときにはChatGPTを使うことで解決できることもあります。

下記のRustコードはXXXをするためのコードです。todo!()の部分の実装を埋めてください。

[コード全文]

ただ、これでも上手く行かないことはもちろんあるので、そういうときは自力で書きましょう。

コードの品質を向上する

ここまでの過程で下記のようなコードを作成することができました。

use std::ops::{Deref, DerefMut};

#[derive(Debug, Clone, Copy)]
pub struct Millimeter(pub f64);

impl Deref for Millimeter {
    type Target = f64;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl DerefMut for Millimeter {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

impl PartialEq<Millimeter> for Millimeter {
    fn eq(&self, other: &Millimeter) -> bool {
        self.0 == other.0
    }
}

impl PartialEq<Centimeter> for Millimeter {
    fn eq(&self, other: &Centimeter) -> bool {
        self.0 == Self::from(*other).0
    }
}

impl PartialEq<Meter> for Millimeter {
    fn eq(&self, other: &Meter) -> bool {
        self.0 == Self::from(*other).0
    }
}

impl PartialOrd<Millimeter> for Millimeter {
    fn partial_cmp(&self, other: &Millimeter) -> Option<std::cmp::Ordering> {
        self.0.partial_cmp(&other.0)
    }
}

impl PartialOrd<Centimeter> for Millimeter {
    fn partial_cmp(&self, other: &Centimeter) -> Option<std::cmp::Ordering> {
        self.0.partial_cmp(&Self::from(*other).0)
    }
}

impl PartialOrd<Meter> for Millimeter {
    fn partial_cmp(&self, other: &Meter) -> Option<std::cmp::Ordering> {
        self.0.partial_cmp(&Self::from(*other).0)
    }
}

impl From<Centimeter> for Millimeter {
    fn from(x: Centimeter) -> Self {
        Self(x.0 * 10.0)
    }
}

impl From<Meter> for Millimeter {
    fn from(x: Meter) -> Self {
        Self(x.0 * 1000.0)
    }
}

#[derive(Debug, Clone, Copy)]
pub struct Centimeter(pub f64);

impl Deref for Centimeter {
    type Target = f64;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl DerefMut for Centimeter {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

impl PartialEq<Millimeter> for Centimeter {
    fn eq(&self, other: &Millimeter) -> bool {
        self.0 == Self::from(*other).0
    }
}

impl PartialEq<Centimeter> for Centimeter {
    fn eq(&self, other: &Centimeter) -> bool {
        self.0 == other.0
    }
}

impl PartialEq<Meter> for Centimeter {
    fn eq(&self, other: &Meter) -> bool {
        self.0 == Self::from(*other).0
    }
}

impl PartialOrd<Millimeter> for Centimeter {
    fn partial_cmp(&self, other: &Millimeter) -> Option<std::cmp::Ordering> {
        self.0.partial_cmp(&Self::from(*other).0)
    }
}

impl PartialOrd<Centimeter> for Centimeter {
    fn partial_cmp(&self, other: &Centimeter) -> Option<std::cmp::Ordering> {
        self.0.partial_cmp(&other.0)
    }
}

impl PartialOrd<Meter> for Centimeter {
    fn partial_cmp(&self, other: &Meter) -> Option<std::cmp::Ordering> {
        self.0.partial_cmp(&Self::from(*other).0)
    }
}

impl From<Millimeter> for Centimeter {
    fn from(x: Millimeter) -> Self {
        Self(x.0 / 10.0)
    }
}

impl From<Meter> for Centimeter {
    fn from(x: Meter) -> Self {
        Self(x.0 * 100.0)
    }
}

#[derive(Debug, Clone, Copy)]
pub struct Meter(pub f64);

impl Deref for Meter {
    type Target = f64;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl DerefMut for Meter {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

impl PartialEq<Millimeter> for Meter {
    fn eq(&self, other: &Millimeter) -> bool {
        self.0 == Self::from(*other).0
    }
}

impl PartialEq<Centimeter> for Meter {
    fn eq(&self, other: &Centimeter) -> bool {
        self.0 == Self::from(*other).0
    }
}

impl PartialEq<Meter> for Meter {
    fn eq(&self, other: &Meter) -> bool {
        self.0 == other.0
    }
}

impl PartialOrd<Millimeter> for Meter {
    fn partial_cmp(&self, other: &Millimeter) -> Option<std::cmp::Ordering> {
        self.0.partial_cmp(&Self::from(*other).0)
    }
}

impl PartialOrd<Centimeter> for Meter {
    fn partial_cmp(&self, other: &Centimeter) -> Option<std::cmp::Ordering> {
        self.0.partial_cmp(&Self::from(*other).0)
    }
}

impl PartialOrd<Meter> for Meter {
    fn partial_cmp(&self, other: &Meter) -> Option<std::cmp::Ordering> {
        self.0.partial_cmp(&other.0)
    }
}

impl From<Millimeter> for Meter {
    fn from(x: Millimeter) -> Self {
        Self(x.0 / 1000.0)
    }
}

impl From<Centimeter> for Meter {
    fn from(x: Centimeter) -> Self {
        Self(x.0 / 100.0)
    }
}

だいたい欲しかったものっぽいものができていますね。
ただ、このコードがレビューで回ってきたら排除する程度の品質のコードのため、もう少しコードとしての品質を上げていきます。

コメントをつける

まず、コードにコメントをつけます。
ChatGPTでコードレビューをするにしてもコードの意図をコードから読み取らねばならず、そこの認識がブレる可能性があるため、まずはコードにコメントをつけて意図を揃えます。

下記のRustコードは長さの単位を表した相互変換、相互比較可能なバリューオブジェクトです。
各structにRustのスタンダードライブラリの書式に合わせたコメントを付与してください。
コメントは英語で付与してください。

[Rustコード]
...

// Represents a length in millimeters.
// Implements Debug, PartialEq, PartialOrd, Clone, and Copy traits.
#[derive(Debug, Clone, Copy)]
pub struct Millimeter(pub f64);

...

// Represents a length in centimeters.
// Implements Debug, PartialEq, PartialOrd, Clone, and Copy traits.
#[derive(Debug, PartialEq, PartialOrd, Clone, Copy)]
pub struct Centimeter(pub f64);

...

// Represents a length in meters.
// Implements Debug, PartialEq, PartialOrd, Clone, and Copy traits.
#[derive(Debug, Clone, Copy)]
pub struct Meter(pub f64);

だいたい、期待するレベルのコメントは出てきません。
そこでこのコメントで使えそうな部分だけを使ってCopilotを使いながらコメントを付与していきます。


一つコメントを作成すればあとはCopilotで横展開することができますので、1つ目は頑張りましょう。
(※ 別件で作ってるツールのプロジェクトの中にサンプルを作っているので、コメントの書き方が引きずられてしまっています)

こうしてできたコメントは下記のような形になります。

/// Represents a length in millimeters.
///
/// # Examples
///
/// ```rust
/// # use uka_util::unit::Millimeter;
/// #
/// let x = Millimeter(10.0);
/// let y = Millimeter(20.0);
/// assert!(x < y);
/// ```
#[derive(Debug, Clone, Copy)]
pub struct Millimeter(pub f64);

個人的な感想にはなりますが、ChatGPTやCopilotを使ったコメントは直接的な説明になりやすく、あまり残したいと思わないコメントになりやすい傾向があると思います。シグネチャ見ればわかるよねというような。

そのため、使うとしたら今回のようにとっかかりとして使用して1つだけコメントを自力で作成してから、同レベルのコメントを生成していくという機会が多いです。

レビューをする

コメントができたらそれを使ってコードレビューを行います。
先にテストコードを作成してもいいのですが、ここで構造が大きく変わることもあるのでレビューを先にすることが多いです。

下記のRustコードのレビューを行なってください。

[Rustコード]
このコードは、Rustにおけるユニット変換(長さの単位変換)の一例を示しています。Millimeter、Centimeter、Meterという3つの構造体を定義し、これらの間で変換が可能であることを示しています。

以下は具体的なレビューコメントです:

1. Precision Loss:浮動小数点数を使用していますが、浮動小数点数は精度の問題を抱えています。例えば、10mmと1cmは等価ですが、浮動小数点数の計算誤差により、これらが等しくないと判断される可能性があります。これを避けるためには、全ての長さを基本単位(この場合はmm)で内部的に保持し、他の単位への変換は表示のためだけに使用するという方法があります。
...

このレビューは人にレビューしてもらうときと同様に、指摘内容を全てその通りに変更しないといけないわけではありません。
例えば浮動小数点の精度問題に関して仕様として許容しているなら対応せずにこの指摘は無視します。
また、これが問題になるとしてもmmで保持することができない理由があるならそれを伝えて、別の案を提案してもらうことも可能です。

> 1. Precision Loss:浮動小数点数を使用していますが、浮動小数点数は精度の問題を抱えています。例えば、10mmと1cmは等価ですが、浮動小数点数の計算誤差により、これらが等しくないと判断される可能性があります。これを避けるためには、全ての長さを基本単位(この場合はmm)で内部的に保持し、他の単位への変換は表示のためだけに使用するという方法があります。

とのことですが、将来的にmm以下の単位を扱うときに全ての単位の変更が必要になり、拡張性に課題が出ます。
他に浮動小数点の精度問題を回避する方法はないでしょうか?

また、わからないことがあれば、そちらについて聞きましょう。

> 1. Precision Loss:浮動小数点数を使用していますが、浮動小数点数は精度の問題を抱えています。例えば、10mmと1cmは等価ですが、浮動小数点数の計算誤差により、これらが等しくないと判断される可能性があります。これを避けるためには、全ての長さを基本単位(この場合はmm)で内部的に保持し、他の単位への変換は表示のためだけに使用するという方法があります。

とのことですが、浮動小数点の精度問題について何故それが起きるのか、一般的にどのように回避するのか教えてください。

指摘事項に合わせて自力で修正してもいいですが、指摘事項を修正したコードを生成してもらうこともの可能です。

> 1. Precision Loss:浮動小数点数を使用していますが、浮動小数点数は精度の問題を抱えています。例えば、10mmと1cmは等価ですが、浮動小数点数の計算誤差により、これらが等しくないと判断される可能性があります。これを避けるためには、全ての長さを基本単位(この場合はmm)で内部的に保持し、他の単位への変換は表示のためだけに使用するという方法があります。

それでは上記の方針でコードを修正してください。

こういったやりとりを繰り返し全ての指摘箇所に対して、コードを変更するか、指摘を無視するかして指摘内容に対応します。
それができたら再度コードを貼ってレビューをしてもらい、すべて無視して良い指摘になるまで繰り返します。
もし、無視して良い指摘内容について言語化できているなら、XXXについては把握しており許容しているため指摘は不要です。とレビュー指示に含めても構いません。(ただし、指摘しなくなる訳ではないのでそこまで重要ではないです)

余談ですが、ChatGPTは基本的に肯定した回答をしやすい傾向があるように思います。
そのため、「この書き方は正しいですか?」と聞くよりは「この書き方は問題があります。問題点を教えてください」というような聞き方はした方が良い指摘を得られやすく感じます。

また、同じような回答しか得られないときは「問題点を30個指摘してください」など数を指定することで、変わった指摘を得られることがあります。
ただし、同じような内容の繰り返しや、的外れな指摘も出やすくなるので使う場面については注意してください。

テストコードの生成

期待したコードができたらテストコードを作成します。

このテストコード自体をどのタイミングで作るかは少し悩ましいです。
ある程度仕様が見えているならコードを作成する前にテストケースを作成することでコード生成の品質が上がりますし、構造を詰め切ってからやりたいならレビュー後に作った方が無駄になりにくいです。

下記のRustコードのMillimeterに対してテストケースが網羅的になるように列挙してください。

[Rustコード]
下記のRustコードのMillimeterに対して網羅的なユニットテストを作成してください。

[Rustコード]

ChatGPTは出力が大きくなると回答が荒くなるため、はじめにケースを網羅してからケースに合うコードを生成してもいいですし、そこまで大きくならないら直接テストコードを生成しても良いと思います。

また、GitHub Copilotってテストを作成するというのも可能です。

ただし、網羅性という意味ではあまり使い勝手はよくなく、同じようなテストの繰り返しや、何かテストで漏れがないかという用途で使うことが多いです。

これは個人的な考えですが、あまりテストケースを生成するという意味では2023/07時点のLLMではあまり信用できてないです。
それよりは、特定のテスケースの実装をする、テストケースの漏れのチェックに使うというような使い方が無難かと思います。

もう一度レビューを行う

テストコードまで書けたら最後にもう一度レビューフェーズを繰り返しましょう。

このとき、ファイルが大きくなりすぎてChatGPTでレビューができないとなったら、おそらくファイルサイズが大きすぎるためファイルを分割しましょう。
これはこのフェーズでやる必要はなく、大きいと感じたら随時ファイルを分割してもらって構いません。
ChatGPTの特性として入出力が大きいと回答が荒くなるため、より小さく入力し、1つのタスクを実行し、出力も小さくできるようにした方が精度の高い回答を得られやすいかと思います。

注意点

注意点としてChatGPTもGitHub Copilotもあまり大きな入力は行えません。
そのため、プロジェクト全体を考慮したプログラミングというのはかなり難しいものになります。

もし、プロジェクトの規約としてこう書く、他でこういう組み方をしているのでこう書くといったことは自分で気を付けるか、言語化して自分で入力する必要があります。

また、そうういった理由により設計レビューを行うことも難しいです。
このあたりは大きな数のトークンを扱えるトランスフォーマーが開発されているため実用化されるのを待ちましょう。

おわりに

LLMが登場したことで、プログラミングの仕方がだいぶ変わってきたなと感じます。

上級者なら自分で書いた方が早くて質が高いことが多く主に抜け漏れなどのチェックに、初学者ならLLMに頼り切った開発をした方が早くて質が高いと、その人のレベル感によっても活用する幅は違います。
しかし、誰もがLLMを活用することでプログラミングの速度と質を向上をしやすい世界になってきたことは間違いないです。

あとはツールとの統合がもう少し進めば手間が減って嬉しいのですが、おそらく綺麗に周り出すようになるのはもう少し先でしょうね。

余談ですが、本記事では大枠を書くことを主題にしているため、あまり細かいテクニックは記載してないです。
例えばTerraformコードで存在しないリソースが出るのだけど・・・とかであれば、関係するドキュメントをプロンプトに含めることで回避できたりします。
そういった、細かいテクニックはあるので、もし上手くできないユースケースがあればコメントしてみてください。僕がわからなくても誰かがコメントしてくれるかもしれません。

Discussion