このサイトの目的は、F#を汎用的な開発言語として、多くの人に使ってもらうことです。
関数型プログラミングが普及し、C#にもラムダ式やLINQのような関数型プログラミングの機能が追加されてくると、C#がF#に追いついてきているように思えます。
皮肉なことに、最近よく聞くようになった意見があります。
- 「C#にはF#の機能がほとんど備わっているので、わざわざF#に乗り換える必要はないでしょう?」
- 「変更する必要はありません。数年待てば、C#にF#の便利な機能が取り込まれるでしょう。」
- 「F#はC#より少し良いですが、乗り換えるほどではありません。」
- 「F#は少し難しそうですが、良さそうです。でも、C#じゃなくてF#を使う理由がわかりません。」
Javaにラムダ式が追加された今、Javaの世界でも、ScalaやClojureとJavaを比べて、同じようなことを言っている人がいるのでしょう。
今回は、F#のことは一旦忘れて、C#(そして他の一般的な言語)に注目します。 そして、C#でのプログラミングは、どんなに関数型プログラミングの機能を充実させても、F#でのプログラミングとは違うということを示したいと思います。
その前に、私がC#を嫌っているわけではないことを、はっきりさせておきます。私はC#がとても好きです。C#は、私が好きな主流言語の一つです。 C#は、一貫性と過去のバージョンとの互換性を保ちながら、とても強力に進化してきました。これは簡単なことではありません。
しかしC#は完璧ではありません。他の多くのオブジェクト指向言語と同じように、C#には、LINQやラムダ式の良さでは補えない設計上の問題点がいくつかあります。
この記事では、C#の設計上の問題点によって起こる問題をいくつか紹介し、それを解決するために言語を改善する方法を提案します。
(炎上対策の準備をしておきます。必要になるかもしれません!)
更新:多くの人がこの記事を誤解しているようです。そこで、はっきりさせておきましょう。
- 静的型付け言語が動的型付け言語より「優れている」と主張しているわけではありません。
- 関数型プログラミング言語がオブジェクト指向言語より「優れている」と主張しているわけではありません。
- コードの推論が言語の最も重要な側面だと主張しているわけではありません。
私が言いたいのは、
- コードの推論ができないことには、多くの開発者が気づいていないコストがかかるということです。
- ですから、「合理的であること」は、プログラミング言語を選ぶ上で考慮すべき点の一つであり、認識不足のために無視すべきではありません。
- コードの推論をしたいなら、この記事で紹介する機能が言語でサポートされていると、とても簡単になります。
- オブジェクト指向の基本的な考え方(オブジェクトの同一性、振る舞いベース)は「合理性」と相容れないので、既存のオブジェクト指向言語にこの性質を追加するのは難しいでしょう。
以上です。ありがとうございました。
「合理的な」プログラミング言語とは?
関数型プログラマーと話していると、「推論する」という言葉をよく耳にします。「プログラムについて推論したい」のように。
どういう意味でしょうか?なぜ「理解する」ではなく「推論する」という言葉を使うのでしょうか?
「推論」という言葉は、数学や論理学で使われていましたが、ここではシンプルで実用的な定義を使います。
- 「コードについて推論する」とは、コードの他の部分を見なくても、目の前にある情報だけで結論を導き出せるということです。
言い換えれば、コードを見るだけで、そのコードがどのように動くか予測できるということです。 他の部分とのやり取りを理解する必要があるかもしれませんが、どのように動くかを知るために、中のコードを見る必要はありません。
開発者は、ほとんどの時間をコードを見て過ごしているので、これはプログラミングにおいてとても重要なことです。
もちろん、良いコードを書くためのアドバイスはたくさんあります。命名規約、整形ルール、デザインパターンなどです。
しかし、プログラミング言語自体が、コードをより合理的で予測しやすいものにするのに役立つのでしょうか?私は、答えは「イエス」だと思いますが、それはあなた自身で判断してください。
以下に、コードの断片をいくつか示します。それぞれのコードの後に、そのコードが何をするかを質問します。 私はわざとコメントを書いていません。コードについて考えて、あなた自身で推論できるようにです。それについて考えた後、下にスクロールして私の意見を読んでください。
例1
次のコードを見てみましょう。
- 変数
x
に整数2
を代入します。 x
を引数として、DoSomething
を呼び出します。y
にx - 1
を代入します。
質問は単純です。y
の値は何ですか?
var x = 2;
DoSomething(x);
// yの値は何ですか?
var y = x - 1;
(答えを見るには下にスクロールしてください)
答えは-1
です。わかりましたか?いいえ?もしわからない場合は、もう一度下にスクロールしてください。
ひっかけ問題でした! このコードは、実はJavaScriptです。
全体は次のとおりです。
function DoSomething (foo) { x = false}
var x = 2;
DoSomething(x);
var y = x - 1;
最低ですね。DoSomething
は、パラメータを使わずに、x
に直接アクセスし、それをブール値に変えてしまいます。
そして、x
から1を引くと、x
はfalse
から0
に型変換されるので、y
は-1
になります。
本当にひどいですよね。言語について誤解させてしまい申し訳ありませんが、言語が予測できない動きをすると、どれほど面倒なことかを示したかっただけです。
JavaScriptはとても便利で重要な言語です。しかし、合理性がJavaScriptの強みの一つだと主張する人はいないでしょう。 実際、動的な型付け言語のほとんどは、このように推論しにくい癖があります。
静的な型付けと適切なスコープ規則のおかげで、C#ではこのようなことは起こりません(無理やりやろうとしない限り)。 C#では、型が正しく一致しないと、実行時にエラーが起こるのではなく、コンパイル時にエラーが起こります。
言い換えれば、C#はJavaScriptよりもはるかに予測しやすいということです。静的型付けの利点です。
これで、言語を予測しやすくするための最初の要件がわかりました。
言語を予測可能にするには:
- 変数の型は変更できないようにする。
JavaScriptと比べると、C#は良さそうです。しかし、まだ終わりではありません。
更新:これは、確かに馬鹿げた例です。今思えば、もっと良い例を選ぶべきでした。 分別のある人なら、こんなことはしないことはわかっています。それでも、ポイントは変わりません。JavaScriptは、暗黙の型変換で愚かなことをするのを防いでくれないのです。
例2
次は、全く同じデータを持つ、同じCustomer
クラスのインスタンスを2つ作ってみます。
さて、この2つは等しいでしょうか?
// 2つの顧客を作る
var cust1 = new Customer(99, "J Smith");
var cust2 = new Customer(99, "J Smith");
// true それとも false?
cust1.Equals(cust2);
(答えを見るには下にスクロールしてください)
// true それとも false?
cust1.Equals(cust2);
さあ、どうでしょう? これはCustomer
クラスがどのように作られているかによって違います。 このコードは予測できません。
少なくとも、クラスがIEquatable
を実装しているかどうかを確認する必要があります。
そして、実際に何が起こっているのかを正確に知るには、クラスの中身を見る必要があるでしょう。
でも、なぜこれが問題になるのでしょう?
こう考えてみてください。
- これまで、インスタンスを等しくしたくないと思ったことはどのくらいありましたか?
- これまで、
Equals
メソッドをオーバーライドする必要があったことはどのくらいありましたか? - これまで、
Equals
メソッドをオーバーライドするのを忘れたためにバグが発生したことはどのくらいありましたか? - これまで、
GetHashCode
の実装を誤ったためにバグが発生したことはどのくらいありましたか?(比較対象のフィールドが変更されたときに変更するのを忘れたなど)
オブジェクトが等しいかどうかを判断する際、デフォルトでは値を比較するようにして、参照の比較が必要な場合のみ特別な処理を行うようにするのはどうでしょうか?
リストに別の項目を追加しましょう。
言語を予測可能にするには:
- 変数の型は変更できないようにする。
- 同じ値を持つオブジェクトは、デフォルトで等しくする。
例3
次は、全く同じデータを持つ2つのオブジェクトを作りますが、異なるクラスのインスタンスにします。
さて、ここでまた同じ質問です。この2つは等しいでしょうか?
// 顧客と注文を作る
var cust = new Customer(99, "J Smith");
var order = new Order(99, "J Smith");
// true それとも false?
cust.Equals(order);
(答えを見るには下にスクロールしてください)
// true それとも false?
cust.Equals(order);
そんな比較に意味はありません! これはほぼ確実にバグです!そもそも、なぜ2つの異なるクラスを比較するのでしょうか?
オブジェクト自体を比較するのではなく、名前やIDを比較してください。異なる型のオブジェクトを比較することは、コンパイルエラーになるべきです。
もしコンパイルエラーにならないのであれば、それはなぜでしょうか? たぶん、あなたは間違った変数名を使ってしまっただけなのに、コードに微妙なバグが潜んでしまうことになります。 なぜ言語はそんなことを許してしまうのでしょうか?
リストに別の項目を追加しましょう。
言語を予測可能にするには:
- 変数の型は変更できないようにする。
- 同じ値を持つオブジェクトは、デフォルトで等しくする。
- 異なる型のオブジェクトを比較すると、コンパイル時エラーになる。
更新:多くの人が、継承関係にあるクラスを比較するときにこれが必要だと指摘しています。もちろん、その通りです。 しかし、この機能のコストは何でしょうか?サブクラスを比較する機能は得られますが、うっかりミスによるエラーを検出する機能は失われます。
実際にはどちらが重要でしょうか?それはあなたが決めることです。私は現状維持には利点だけでなくコストも伴うことを明確にしたかっただけです。
例4
このコードでは、Customer
インスタンスを作るだけです。本当にそれだけ。これ以上シンプルなコードはありません。
// 顧客を作成する
var cust = new Customer();
// 期待される出力は?
Console.WriteLine(cust.Address.Country);
さて、質問です。WriteLine
の出力はどうなるでしょうか?
(答えを見るには下にスクロールしてください)
// 期待される出力は?
Console.WriteLine(cust.Address.Country);
さあ、どうなるでしょう?
これは、Address
プロパティがnullかどうかによって変わります。そして、それを知るには、またCustomer
クラスの中身を見なければなりません。
コンストラクターは、オブジェクトを作るときにすべてのフィールドを初期化するべきだというベストプラクティスはありますが、 なぜ言語はそれを強制しないのでしょうか?
もしアドレスが必要なら、コンストラクターで必須にすれば良いのです。
そして、アドレスが必ずしも必要ないなら、Address
プロパティはオプションで、値がない場合もあることを明確に示すべきです。
リストに項目をもう一つ追加しましょう。
言語を予測可能にするには:
- 変数の型は変更できないようにする。
- 同じ値を持つオブジェクトは、デフォルトで等しくする。
- 異なる型のオブジェクトを比較すると、コンパイル時エラーになる。
- オブジェクトは常に有効な状態に初期化されなければならない。そうでない場合はコンパイル時エラーになる。
例5
次の例では、こんなことをやってみます。
- まず、顧客を作ります。
- それを、ハッシュを使った集合に追加します。
- そして、その顧客オブジェクトで何らかの処理を行います。
- 最後に、その顧客がまだ集合に含まれているかどうかを確認します。
何が問題になる可能性があるでしょうか?
// 顧客を作成する
var cust = new Customer(99, "J Smith");
// 集合に追加する
var processedCustomers = new HashSet<Customer>();
processedCustomers.Add(cust);
// 処理を行う
ProcessCustomer(cust);
// 集合に顧客が含まれているか? true それとも false?
processedCustomers.Contains(cust);
では、このコードの最後で、集合に顧客はまだ含まれているでしょうか?
(答えを見るには下にスクロールしてください)
// 集合に顧客が含まれているか?
processedCustomers.Contains(cust);
もしかしたら含まれているかもしれませんし、含まれていないかもしれません。
これは2つの要素に依存します。
- 1つ目は、顧客のハッシュコードが、IDなどの可変フィールドに依存しているかどうかです。
- 2つ目は、
ProcessCustomer
がこのフィールドを変更するかどうかです。
もしこれらの条件が両方とも当てはまる場合、ハッシュ値は変更されてしまい、顧客は集合から見かけ上消えてしまいます(実際にはまだどこかに残っているのですが!)。
これは、パフォーマンスやメモリ使用量に微妙な問題を引き起こす可能性があります(たとえば、集合がキャッシュとして使われている場合など)。
プログラミング言語はどうすればこの問題を防ぐことができるでしょうか?
1つの方法は、GetHashCode
で使用されるフィールドまたはプロパティは不変でなければならないが、他のプロパティは可変でもよいとすることです。しかし、それは実際には非現実的です。
代わりに、Customer
クラス全体を不変にする方が良いでしょう!
Customer
クラスが不変であり、ProcessCustomer
が変更を加えたい場合は、顧客の新しいバージョンを返す必要があり、コードは次のようになります。
// 顧客を作成する
var cust = new ImmutableCustomer(99, "J Smith");
// 集合に追加する
var processedCustomers = new HashSet<ImmutableCustomer>();
processedCustomers.Add(cust);
// 処理を行い、変更を返す
var changedCustomer = ProcessCustomer(cust);
// true それとも false?
processedCustomers.Contains(cust);
ProcessCustomer
の行が以下のように変更されていることに注目してください。
var changedCustomer = ProcessCustomer(cust);
ProcessCustomer
が何かを変更したことは、このコードを見るだけで明らかです。
ProcessCustomer
が何も変更しなかった場合、オブジェクトを返す必要はまったくありません。
質問に戻ると、この実装では、ProcessCustomer
が何をするかに関係なく、元の顧客が集合に含まれていることが保証されていることは明らかです。
もちろん、新しいものと古いもののどちらを(または両方)集合に入れるべきかという問題は解決しません。 しかし、可変の顧客を使う実装とは異なり、この問題は明確になり、見落とされることはありません。
というわけで、不変性は最高!
リストに項目をもう一つ追加しましょう。
言語を予測可能にするには:
- 変数の型は変更できないようにする。
- 同じ値を持つオブジェクトは、デフォルトで等しくする。
- 異なる型のオブジェクトを比較すると、コンパイル時エラーになる。
- オブジェクトは常に有効な状態に初期化されなければならない。そうでない場合はコンパイル時エラーになる。
- 作成後は、オブジェクトとコレクションは不変でなければならない。
不変性についてのちょっとしたジョーク:
「電球を変えるには何人のHaskellプログラマーが必要ですか?」
「Haskellプログラマーは電球を“変え”ません。“取り替え”ます。それも、家ごとそっくり“取り替え”なければなりません。」
ほぼ完了ですが、あと一つだけ残っています!
例6
最後の例では、CustomerRepository
から顧客を取得してみましょう。
// リポジトリを作成する
var repo = new CustomerRepository();
// IDで顧客を検索する
var customer = repo.GetById(42);
// 期待される出力は?
Console.WriteLine(customer.Id);
では、質問です。customer = repo.GetById(42)
を実行した後、customer.Id
の値は何でしょうか?
(答えを見るには下にスクロールしてください)
var customer = repo.GetById(42);
// 期待される出力は?
Console.WriteLine(customer.Id);
もちろん、場合によりますよね。
GetById
のメソッドシグネチャを見ると、常にCustomer
を返すように見えます。でも、本当にそうでしょうか?
顧客が見つからない場合はどうなるのでしょう? repo.GetById
はnull
を返すのでしょうか? 例外をスローするのでしょうか? 今のコードを見るだけでは、わかりません。
特に、null
を返すのは最悪です。 null
はCustomer
のふりをしておいて、コンパイラに怒られることなくCustomer
変数に代入できます。
ところが、いざ何かしようとすると、邪悪な笑い声とともに、あなたの顔の前で爆発します。残念ながら、このコードからnullが返されるかどうかは、見分けることができません。
例外処理の方がまだマシです。少なくとも型指定されていて、コンテキスト情報が含まれているからです。 しかし、メソッドシグネチャを見ても、どんな例外がスローされるのかはわかりません。 確実に知るには、ソースコードの中身を見るしかありません(運が良ければ最新のドキュメントもあるでしょう)。
では、もし言語がnull
も例外も許さないとしたらどうでしょう? どうすれば良いのでしょうか?
答えは、顧客またはエラーのいずれかを含む特別なクラスを返すように強制することです。
// リポジトリを作成する
var repo = new CustomerRepository();
// IDで顧客を検索し、
// CustomerOrError結果を返す
var customerOrError = repo.GetById(42);
この「customerOrError」という結果を処理するコードは、それがどんな種類の結果なのかをテストし、それぞれの場合を個別に処理する必要があります。
// 両方のケースを処理する
if (customerOrError.IsCustomer)
Console.WriteLine(customerOrError.Customer.Id);
if (customerOrError.IsError)
Console.WriteLine(customerOrError.ErrorMessage);
これは、ほとんどの関数型言語で採用されているアプローチです。 直和型など、この手法をサポートする便利な機能があれば、言語として役立ちますが、 たとえなくても、コードの動作を明確にしたい場合は、このアプローチが唯一の方法です。(この手法の詳細については、こちらをご覧ください。)
これで、リストに最後の2つの項目を追加できました。
言語を予測可能にするには:
- 変数の型は変更できないようにする。
- 同じ値を持つオブジェクトは、デフォルトで等しくする。
- 異なる型のオブジェクトを比較すると、コンパイル時エラーになる。
- オブジェクトは常に有効な状態に初期化されなければならない。そうでない場合はコンパイル時エラーになる。
- 作成後は、オブジェクトとコレクションは不変でなければならない。
- nullは許可されない。
- 欠落データまたはエラーは、関数シグネチャで明示的にする必要がある。
グローバル変数、副作用、キャストなどの誤用を示すコードスニペットをもっと紹介することもできますが、この辺りでやめておきましょう。もうお分かりいただけたかと思います!
あなたのプログラミング言語はこれができますか?
プログラミング言語にこれらの追加を行うことで、より合理的になることは明らかだと思います。
残念ながら、C#のような主流のオブジェクト指向言語は、これらの機能を追加する可能性は非常に低いです。
まず、既存のコードすべてに大きな変更を加えることになります。
さらに、これらの変更の多くは、オブジェクト指向プログラミングモデル自体と根本的に矛盾しています。
たとえば、オブジェクト指向モデルでは、オブジェクトの同一性が最も重要なので、当然参照による等価性がデフォルトになります。
また、オブジェクト指向の観点からは、2つのオブジェクトをどのように比較するかは、完全にオブジェクト自体に委ねられます。 オブジェクト指向は多態的な振る舞いに関するものであり、コンパイラが口出しすべきではありません! 同様に、オブジェクトをどのように構築して初期化するかも、完全にオブジェクト自体に委ねられます。 何が許可されるべきか、許可されるべきでないかについての規則はありません。
最後に、静的に型付けされたオブジェクト指向言語に、項目4の初期化制約を実装せずに、null非許容参照型を追加することは非常に困難です。 Eric Lippert氏が述べているように、「null非許容性は、12年後に後付けしたいものではなく、最初から型システムに組み込みたいものです」。
対照的に、ほとんどの関数型プログラミング言語は、これらの「予測可能性が高い」機能を言語の中核部分として備えています。
たとえば、F#では、リストされている項目の1つを除くすべてが言語に組み込まれています。
- 値は型を変更できません。(これは、たとえばintからfloatへの暗黙的なキャストも含まれます)。
- 同じ内部データを持つレコードは、デフォルトで等しくなります。
- 異なる型の値を比較することは、コンパイル時エラーです。
- 値は必ず有効な状態に初期化する必要があります。そうでない場合はコンパイル時エラーになります。
- 作成後、値はデフォルトで不変です。
- 一般的に、nullは許可されません。
項目7はコンパイラによって強制されるものではありません。しかし、一般的には、エラーを返すために例外ではなく判別共用体(直和型)が使われます。 その結果、関数シグネチャは、どのようなエラーが発生する可能性があるのかを正確に示すことができるのです。
確かに、F# を使う場合でも、まだ注意すべき点はたくさんあります。たとえば、可変の値を使うこともできますし、例外を生成してスローすることもできます。また、F# 以外のコードから null が渡される場合もあるでしょう。
しかし、F# では、このようなコードは一般的ではなく、むしろ「コードの臭い」と見なされます。
Haskellなどの他の言語は、F#よりもさらに純粋です(したがって、より合理的です)。 しかし、Haskellプログラムでさえ完璧ではありません。
実際、完全に推論可能でありながら実用的な言語はありません。 しかしそれでも、一部の言語は確かに他の言語よりも合理的です。
多くの人が関数型スタイルのコードに熱中する(たとえそれが奇妙な記号でいっぱいだったとしても、「シンプル」と呼ぶ)、その理由の一つはまさにこれだと思います。 不変性、副作用の欠如、そして他のすべての関数型プログラミングの原則は、連携して作用し、コードの合理性と予測可能性を高めます。 その結果、認知的な負担が軽減され、目の前のコードのみに集中できるようになるのです。
ラムダ式は解決策ではありません
ここまで読んで、私が提案した改善点が、ラムダ式や高階関数といった言語の拡張とは関係ないことは、もうお分かりでしょう。
つまり、私が合理性に注目するということは、言語で何ができるかよりも、言語で何ができないかの方に関心があるということです。 私は、間違って愚かなことをしてしまうのを防いでくれる言語を求めているのです。
言い換えれば、もしnullを許容しない言語Aと、高階型をサポートしているけれどnullを簡単に許容してしまう言語Bのどちらかを選ばなければならないとしたら、 私は迷わず言語Aを選びます。
よくある質問
いくつか予想される質問に答えておきましょう。
質問:例として挙げているコードは、ちょっと不自然じゃないですか?注意深くコーディングして、良いプラクティスに従えば、これらの機能がなくても安全なコードは書けますよね?
はい、その通りです。安全なコードを書けないとは言ってません。しかし、この記事は安全なコードを書くことではなく、コードについて推論することについてです。コードについて推論するのと、安全なコードを書くのとは、違います。
注意深くコーディングすれば安全なコードを書ける、ということではありません。注意を怠ったときに何が起こるか、ということです! つまり、プログラミング言語は(コーディングガイドライン、テスト、IDE、開発プラクティスではなく)、コードについて推論するためのサポートを提供してくれるのでしょうか?
質問:あなたは、言語はこれらの機能を持つべきだと言っているのですか? ちょっと傲慢じゃないですか?
この記事をよく読んでください。私はそんなことは一言も言っていません。私が言っているのは、
もしあなたが自分のコードについて推論できるようにしたいのであれば、そうすれば私が言及する機能をあなたの言語がサポートしていれば、はるかに簡単になります。
ということです。もし、コードについて推論することがそれほど重要ではないのであれば、私の言っていることは無視してください!
質問:プログラミング言語の一つの側面だけに注目するのは、あまりにも限定的すぎませんか? 他の特性も同じくらい重要なのではありませんか?
はい、もちろんです。私はこのトピックに関して絶対主義者ではありません。 包括的なライブラリ、優れたツール、歓迎的なコミュニティ、エコシステムの強さなども非常に重要だと思います。
しかし、この記事の目的は、冒頭で述べたような「C#にはすでにF#のほとんどの機能があるので、なぜわざわざ乗り換える必要があるのですか?」といった意見に答えることでした。
質問:あなたはなぜ、動的型付け言語をそんなに早く切り捨てるのですか?
まず、先ほどのJavaScriptの例について、JavaScript開発者にお詫び申し上げます!
私は動的型付け言語も好きです。そして、私のお気に入りの言語の一つであるSmalltalkは、この記事で話してきた基準からすると全く合理的ではありません。 この記事は、どの言語が一般的に「最高」なのかを主張しようとしているのではなく、言語選択の一つの側面について議論しているだけです。
質問:不変データ構造は遅く、多くの追加のメモリ割り当てが発生します。これはパフォーマンスに影響しませんか?
この記事では、これらの機能がパフォーマンスに与える影響(またはその他の側面)については扱いません。
しかし、コードの品質とパフォーマンスのどちらを優先すべきかという質問は、確かに重要です。それはあなたが決めることであり、状況によって異なります。
個人的には、そうしないだけの説得力のある理由がない限り、安全性と品質を優先します。私が好きな標語があります。
まとめ
この記事は「合理性」だけで言語を選ぶように説得しようとしているのではない、と先ほど述べました。しかし、それは完全には真実ではありません。
もしあなたがすでにC#やJavaのような静的型付けの高水準言語を選んでいるのであれば、 合理性のようなものは、あなたの言語選択において重要な基準であったはずです。
その場合、この記事の例が、あなたが選んだプラットフォーム(.NETまたはJVM)上で、さらに「合理的な」言語を使ってみようという気になるきっかけになれば幸いです。
現状維持の論拠、つまり「今の言語が最終的には『追いつく』だろう」という論拠は、純粋に機能の面では正しいかもしれません。 しかし、将来的な機能強化がどれだけ行われても、オブジェクト指向言語のコアとなる設計思想を変えることはできません。 nullや可変性、等価性のオーバーライドの必要性から逃れることはできないでしょう。
F#やScala/Clojureの良い点は、これらの関数型プログラミング言語の選択肢は、エコシステムを変える必要がなく、コードの品質をすぐに改善できることです。
従来通りのビジネスのコストと比較して、リスクはかなり低いと思います。
(スキルのある人材の確保、トレーニング、サポートなどの問題は、別の記事で取り上げます。 しかし、採用について心配な場合は、これ、 これ、 これ、 これをご覧ください。)