🔖

ChatGPTでHuman in the loopにコード書くにはTDDが現状いいのではと思い試してみた

2023/03/21に公開

AI技術の進化を楽しみに思いつつ将来の仕事どうしようかと思ってる @na9amura です。

ChatGPTGitHub Copilot などを利用してコードを自動生成してみて遊んでます。出てくるコードのクオリティも期待値以上で素晴らしくて感心してます。一方で結果の正しさ、権利、仕事で使う場合の機密など迷いなくプロダクトに利用するにはクリアしたい課題がある状況です。

課題の一つである結果の確認をしないといけないという点は、TDDに似ているのではとふと思いました。結果の正しさを担保する方法としてTDDでテストコードを人間が書き、アプリケーション側のコードをChatGPTに書いてもらうのはもしかしたら良いバランスではないか。伝える手段が文章ではなく実行できるテストコードなのも、AIによるアウトプットの検証にそのまま使えるという算段です。
もちろん権利や機密を始めとした他の問題もあるのでこれだけで「明日から仕事に使える!」とはならないですが、結果を採用するかどうかを人間が判断する、採用するための基準が出力前に明確になっているのとスムーズなのではと考えて試してみました。

先に結論と感想

TDDスタイルだと言語で意図を伝えるよりも楽になるとは思いました。特にこのようなコード生成の場合は文章より明確に意図を伝えやすいです。なのでもしテストコードを書ける人がChatGPTを使うならメリットはある。自動テストをしっかり書く開発チームを前提とすると、テスト書く時間だけで実装が済むのは時短になるかもしれません。
ですがその人がコードを書けば良いという結論になるかもしれないですね。いちプログラマーとしてChatGPTに指示を出し続けるのが楽しいのか、やりがいを感じるのかという疑問もありますし。今回やってみて、経験は薄いけど理解度がとても高い人のコードレビューを繰り返してる感覚でした。プロダクトを世に出すことにフォーカスするフェーズなどでは良いんですかね。

コードの品質面では、一定のクオリティに辿り着くことも可能だと感じました。ですがPrompt書く人のコーディングの知識に左右される部分がかなり大きそうです。そういった観点で見るとソフトウェアエンジニアのスキル、TDDやソフトウェアアーキテクチャの知識などはすぐに陳腐化しないと思いました。進化が凄まじいのでいつまでそう言えるか不明ですが。

ChatGPT with TDD

今回は rangeOfという関数を作ってみることにしました。指定した初期値を指定した数だけ増やすという関数です。せっかくなので数字だけじゃなく文字も扱えるようにしましょう。

1回目

まずはテストコードを与えてみます。英語で指示した方が賢いと聞いたのでちょっと頑張って英語で指示を出してみました。

Prompt

Let's start Test-Driven Development!
Write the implementation of `rangeOf` satisfies these test cases using TypeScript.


import {expect, test} from '@jest/globals';

test("range of numbers from 1", () => {
	expect(rangeOf(1, 5)).toBe([1,2,3,4,5]);
});

test("range of numbers from 10", () => {
	expect(rangeOf(10, 4)).toBe([11,12,13,14]);
});

test("range of string", () => {
	expect(rangeOf("a", 5)).toBe(["a", "b", "c", "d", "e"]);
});

Answer

function rangeOf(start: number | string, length: number): Array<number | string> {
  const isString = typeof start === "string";
  const startNum = isString ? start.charCodeAt(0) : (start as number);
  const result = [];
  
  for (let i = 0; i < length; i++) {
    result.push(isString ? String.fromCharCode(startNum + i) : startNum + i);
  }

  return result;
}

結果はこちら。愚直ではありますが動作はしそう。ちょっと拡張性に乏しいのでStrategyパターンを使ってもらいましょう。

2回目

Prompt

Can you improve your code using strategy pattern?

Answer

interface RangeStrategy<T> {
  getNextValue(current: T): T;
}

class NumberRangeStrategy implements RangeStrategy<number> {
  getNextValue(current: number): number {
    return current + 1;
  }
}

class StringRangeStrategy implements RangeStrategy<string> {
  getNextValue(current: string): string {
    return String.fromCharCode(current.charCodeAt(0) + 1);
  }
}

function rangeOf<T>(start: T, length: number, strategy: RangeStrategy<T>): T[] {
  const result: T[] = [start];

  for (let i = 1; i < length; i++) {
    result.push(strategy.getNextValue(result[i - 1]));
  }

  return result;
}

良い感じではあるけれど関数のインターフェイスを変えて来ましたね。議論が分かれるかもしれないですが、このくらいの関数ではStrategyオブジェクトを外から渡さない方がシンプルかなと思います。なのでそう修正してもらってみましょう。

3回目

Prompt

Can you change your code to detect strategy class by type of argument?
type RangeStrategy = {
  getNextValue: (currentValue: number | string) => number | string
}

const NumberRangeStrategy: RangeStrategy = {
  getNextValue: (currentValue: number) => currentValue + 1
}

const StringRangeStrategy: RangeStrategy = {
  getNextValue: (currentValue: string) => String.fromCharCode(currentValue.charCodeAt(0) + 1)
}

function getRangeStrategy(start: number | string): RangeStrategy {
  return typeof start === "string" ? StringRangeStrategy : NumberRangeStrategy;
}

function rangeOf(start: number | string, length: number): Array<number | string> {
  const result = [start];
  let currentValue = start;
  const strategy = getRangeStrategy(start);

  for (let i = 1; i < length; i++) {
    currentValue = strategy.getNextValue(currentValue);
    result.push(currentValue);
  }

  return result;
}

良い感じになりました。ですがこのままでは引数 start: number | striing と戻り値 Array<number | string> の組み合わせが制限されません。なのでジェネリクスを使って改善してみましょう。

4回目

Prompt

Would you improve your code using generics?

Answer

Copy code
type RangeStrategy<T> = {
  getNextValue: (currentValue: T) => T
}

const NumberRangeStrategy: RangeStrategy<number> = {
  getNextValue: (currentValue: number) => currentValue + 1
}

const StringRangeStrategy: RangeStrategy<string> = {
  getNextValue: (currentValue: string) => String.fromCharCode(currentValue.charCodeAt(0) + 1)
}

function getRangeStrategy<T>(start: T): RangeStrategy<T> {
  return typeof start === "string" ? StringRangeStrategy : NumberRangeStrategy;
}

function rangeOf<T>(start: T, length: number): Array<T> {
  const result = [start];
  let currentValue = start;
  const strategy = getRangeStrategy(start);

  for (let i = 1; i < length; i++) {
    currentValue = strategy.getNextValue(currentValue);
    result.push(currentValue);
  }

  return result;
}

今度こそいい感じですね。今回はここまでで終了します。

ChatGPR without TDD

比較として言葉で指定してみます。

V1

Prompt

Implement a function named `rangeOf` using TypeScript. 

The function receives two arguments and returns an array of values with the same type as the first argument. 

The first argument indicates the start value, and the second one indicates the increment number. For the first argument, I'll set a variable like 1, 42, "a" or "R". 

Answer

function rangeOf<T>(start: T, increment: number): T[] {
  const result: T[] = [];
  let current: any = start;

  for (let i = 0; i < increment; i++) {
    result.push(current);
    current = (current as any) + 1;
  }

  return result;
}

最低限は動きそうですが current = (current as any) + 1 あたりは想定した挙動ではないです。文章を追加して改善してみましょう。

V2

Prompt

Implement a function named `rangeOf` using TypeScript. 

The function receives two arguments and returns an array of values with the same type as the first argument. 

The first argument indicates the start value, and the second one indicates the increment number. For the first argument, I'll set a variable like 1, 42, "a" or "R". 
Your function should increment the value in the first argument by one as specified in the second argument and accumulate it into the return array. If the first argument were a number, add 1 to the previous value. In case the first argument was a string, the next value should be the next alphabet.

Answer

function rangeOf<T>(start: T, increment: number): T[] {
  const result: T[] = [];

  for (let i = 0; i < increment; i++) {
    if (typeof start === "number") {
      result.push(start + i as unknown as T);
    } else if (typeof start === "string") {
      result.push(String.fromCharCode(start.charCodeAt(0) + i) as unknown as T);
    } else {
      throw new Error("Unsupported type for start argument.");
    }
  }

  return result;
}

これでTDDで指定したケースと近いところまでできた感じがします。

感想

真面目な結論は先に書いたのでそちらで。感想として、求めるアウトプットに近づくよう試行錯誤をするのは楽しかったです。一方で今回は設定した内容が自分の中にイメージがあるものを取り出すような課題だったのでもどかしさもありました。テストコードを大量に書いておいて一気に並列でコード生成をする、出来上がったらフィードバックする、というフローを組めるならアウトプットの総量は上がりそう。
あと堅めなテーマを設定しましたが、他の方のようなチャレンジングな発想もしてみたいので今後良いアイディア出たらまた記事にしてみたいと思います。

Discussion