[翻訳]なぜ行くのがそれほど良くないのか

みなさんこんにちは! 最近、 TJ HolowaychukがNode.jsに別れを告げ、Goに移行することを決定した方法に関する記事の翻訳がありました。 記事の最後に、Go言語の比較と批判に専念するWill Jagerの 投稿へのリンクがありました。Go言語は翻訳するように求められていました。実際、翻訳結果に精通することを提案します。 私は多かれ少なかれ、著者に内在する詳細な表現スタイルと、文と段落への元の内訳の両方を保存しようとしました。

翻訳、タイプミス、および/またはデザインに関する建設的なコメントや提案を喜んでいますが、翻訳者の視点が翻訳された記事の著者の位置と一致しない可能性があることを覚えておいてください。



Goがそれほど良くない理由



私は囲likeが好きです。 私はいくつかのことのためにそれを使用します(執筆時のこのブログを含む) Goは便利です。 ただし、Goは良い言語ではありません。 もちろん、彼は悪くないが、良くない。



最終的に動けなくなる可能性があるため、あまり良くない言語の使用には注意する必要があります。そして、それらを20年間使用しなければなりません(PHPのように-約 翻訳者]。

以下に、Goに関する主な苦情のリストを示します。 それらのいくつかは非常に一般的であり、いくつかは非常にまれです。



また、 RustHaskellの言語 (私は良い言語だと考えています)と比較します-私が話す問題が実際に解決されたことを示すために(他の言語で-約 翻訳者]。



汎化



問題の本質


さまざまな用途に使用できるコードを書きたいとします。 たとえば、数字のリストを要約する関数を作成する場合、浮動小数点数のリスト、整数のリスト、追加できる他のタイプの要素のリストに使用できると便利です。 また、このコードが、各タイプの個別の関数(整数のリスト、浮動小数点数のリストなど)の個別の関数と同じタイプの安全性と速度を提供するのも素晴らしいでしょう。



適切なアプローチ: 制約パラメトリック多型を 伴う 一般化


汎化の既存の実装の中で最高のものは、Rust言語とHaskell言語に存在するものであると思います(「制限されたタイプのシステム」とも呼ばれます)。 Haskellのバージョンは「タイプクラス」と呼ばれ、Rustのバージョンは「traits」[または「不純物」/「mixins」と呼ばれます。 翻訳者]。 これらは次のようになります。



(Rust、 バージョン0.11



fn id<T>(item: T) -> T { item }
      
      





(ハスケル)



 id :: t -> t id a = a
      
      





この合成例では、渡されたパラメーターを単に返す汎用パラメーターを使用してid



関数を定義しました。 すばらしいことは、この関数はどのタイプでも機能し、どのタイプでも機能しないことです。 HaskellとRustの両方で、この関数は渡されたパラメーターの型を保存し、型の安全性を提供し、一般化のサポートにより実行時に追加コストを作成しません。 たとえば、 clone



関数を書くことができます。



汎化は、データ構造の定義にも使用できます。 例えば



(錆)



 struct Stack<T> { items: Vec<T> }
      
      





(ハスケル)



 data Stack t = Stack [t]
      
      





繰り返しますが、静的型の安全性と、一般化のサポートによる実行中のパフォーマンスの損失はありません。



パラメータを使用して何かを行う一般化された関数を作成する場合は、これらのアクションが定義されているパラメータでのみ機能することをコンパイラに伝える必要があります。 たとえば、3つのパラメーターを加算して合計を返す関数を定義する場合、パラメーターが加算をサポートする必要があることをコンパイラーに説明する必要があります。 次のようなことができます:



(錆)



 fn add3<T:Num>(a:T, b:T, c:T) -> T { a + b + c }
      
      





(ハスケル)



 add3 :: Num t => t -> t -> t -> t add3 abc = a + b + c
      
      





ここでコンパイラーに次のように言います:「 add3



関数のパラメーターは任意の型T



にすることができますが、 T



Num



(数値型)のサブタイプの1つでなければならないという制限があります」。 コンパイラは数値型に対して加算が定義されていることを知っているため、コードは型チェックに合格します。 このような制限は、データ構造の定義にも使用できます。 さて、これは非常にエレガントでシンプルな方法で、一般化されたタイプセーフで拡張可能なプログラミングです。



Goアプローチ: interface{}





非常に平凡な型システムのため、Goは汎用プログラミングのサポートが非常に貧弱です。



一般化された関数の類似性は非常に簡単に記述できます。 たとえば、ハッシュ可能なオブジェクトのハッシュを表示する関数を作成するとします。 これを行うには、型安全性保証を使用してこれを行うことができるインターフェイス、つまり次のようなものを定義できます。



 type Hashable interface { Hash() []byte } func printHash(item Hashable) { fmt.Println(item.Hash()) }
      
      





これで、インターフェースを実装するHashable



オブジェクトを渡すことができ、静的な型チェックも実行されます。これは一般に良いことです。



しかし、ジェネリック型でデータ構造を定義したい場合はどうなりますか? 単純なLinkedList



リンクリストタイプをLinkedList



しましょう。 Goでこれを行う一般的な方法を次に示します。



 type LinkedList struct { value interface{} next * LinkedList } func (oldNode * LinkedList) prepend(value interface{}) * LinkedList { return &LinkedList{value, oldNode} } func tail(value interface{}) * LinkedList { return &LinkedList{value, nil} } func traverse(ll * LinkedList) { if ll == nil { return } fmt.Println(ll.value) traverse(ll.next) } func main() { node := tail(5).prepend(6).prepend(7) traverse(node) }
      
      





何か気づいたことがありますか? value



フィールドのタイプはinterface{}



です。 ここで、 interface{}



は「ベースタイプ」と呼ばれるものです。つまり、他のすべてのタイプはそこから継承されます。 これは、JavaのObject



クラスに直接相当します。 くそー

(注:Goにはサブタイプが存在しないことを意味するため、基本型がGoに存在するかどうかについては意見の相違があります。それにもかかわらず、類似性は残っています。)



Goで一般化された構造を構築する「正しい」方法は、エンティティを基本型にキャストしてから、それらをデータ構造に追加することです。 これは、2004年以降Javaで一般的に行われていたことです。 その後、人々はこのアプローチが型システムを完全に破壊することに気付きました。 このようにデータ構造を使用すると、厳密な型システムのすべての利点が失われます[実際、ここでは大きな問題は見当たりません-基本的なinterface{}



代わりに、原則として、より具体的なインターフェイスを指定して、特定の実装でこの方法を損なわないようinterface{}



することができますタイプ-約 翻訳者]。



たとえば、絶対に正しいコードは次のとおりです。



 node := tail(5).prepend("Hello").prepend([]byte{1,2,3,4})
      
      





これにより、構造化されたプログラムの利点が失われます。 たとえば、メソッドはパラメーターとして整数の連結リストを期待していますが、突然、鼻に締め切りがあるような疲れた永続的なコーヒープログラマーが突然ラインを送信します。 Goの一般化された構造は値のタイプについて何も知らないため、コンパイラは何も気づかないでしょう。そしていつか、プログラムはinterface{}



へのキャストで単純にクラッシュしinterface{}







list



map



graph



tree



queue



、一般化された構造でも同様の問題が発生する可能性がありqueue







言語の拡張性



問題の本質


高水準言語には、多くの場合、より複雑なタスクの略語であるキーワードと記号があります。 たとえば、多くの言語では、少なくとも同じ配列のデータコレクションのすべての要素をバイパスする方法があります。



(Java):



 for (String name : names) { ... }
      
      





(Python):



 for name in names: ...
      
      





組み込み言語(配列など)だけでなく、任意のコレクションに同様の構文糖を使用できると便利です。



また、加算などの型の操作を定義して、次のようなものを記述できれば便利です。

[スピーチ、たとえば、 演算子のオーバーロードについて-約。 翻訳者]



 point3 = point1 + point2
      
      





適切なアプローチ:演算子は関数です


適切な解決策は、演算子を対応する関数/メソッドへの「ショートカット」にし、キーワードを標準の関数/メソッドのエイリアスにすることです。



一部の言語:Python、Rust、Haskellなど -演算子の再定義を許可します[そしてHaskellは独自に定義します-約 翻訳者]。 必要なのは、クラスメソッドを記述することだけです。その後、何らかの演算子(たとえば、「 +



」)を使用すると、対応するメソッドが単純に呼び出されます。 Pythonでは、 +



演算子は__add__()



呼び出します。 Rustでは、「 +



」がAdd



トレイトでadd()



メソッドとして定義されています。 Haskellでは、「 +



」はNum



型で定義された+



関数に対応します。



多くの言語は、 for-each



などのさまざまなキーワードの範囲を拡大する方法をサポートしていfor-each



。 Haskellにはループはありませんが、Rust、Java、Pythonなどの言語には、あらゆるタイプのコレクションでfor-each



を使用できるイテレーターがあります。



反対に、演算子を再定義して、まったく直観的ではない操作を行うことができます。 たとえば、bydlokoder [orig。 「クレイジーコーダー」-約 翻訳者]は、「 -



」演算子を2つのベクトルの乗算として定義できますが、どの言語でも関数を呼び出すことができないため、演算子自体のオーバーロードの問題ではありません。



進入アプローチ:なし


Goは、演算子のオーバーロードとキーワードの拡張使用をサポートしていません。



しかし、突然、 range



キーワードを他の何か(ツリーまたはリンクリスト)で使用したい場合はどうでしょうか。 動作しません。 言語はこれをサポートしていません。 range



は、組み込み構造でのみ使用できます。 make



と同じこと-メモリの割り当てと組み込み構造のみの初期化に使用できます。



拡張可能なイテレータに最も近い利用可能な例えは、 chan



チャネル -約Translator)を返すデータ構造上にラッパーを記述し、それを反復処理することですが、これは遅く、複雑で、多くのバグを引き起こす可能性があります。



このアプローチは次のように正当化されます。「理解しやすく、ページに記述されているコードは実行されるコードです。」 つまり、Goがrange



などの拡張を許可する場合、これは混乱を招く可能性があります。特定のケースのrange



実装の詳細が明らかでない場合があるためです。 しかし、私にとってこの議論はほとんど意味がありません。なぜなら、Goがシンプルで便利であるかどうかに関係なく、人々はデータ構造を移動する必要があるからです。 実装の詳細をrange



後ろに隠すのではなく、実装の詳細を別のヘルパー関数の後ろに隠す必要があります。それほど大きな改善はありません。 よく書かれたコードは読みやすく、不完全に書かれたコードは難しく、明らかにGoはそれを変更できません。



基本的なケースとエラーチェック



問題の本質


再帰的なデータ構造(リンクリストまたはツリー)を使用する場合、構造の終わりにまだ到達していないことを示す方法が必要です。



失敗する可能性のある関数、またはデータを含まない可能性のあるデータ構造を使用して、タスクが失敗することを示したいと考えています。



Go: Nil



アプローチとマルチリターン


最初に囲approachアプローチについて説明します。その後、正しいアプローチを説明しやすくなります。

Goにはnullポインタであるnil



があります 。 このような新しくて現代的な言語、いわゆるtabula rasaが 、この不必要な松葉杖のバグを引き起こす機能を実装するのは恥ずべきことだと思います。



nullポインターには、バグが豊富な長い歴史があります。 歴史的および実用的な理由から、使用されるデータはほとんど0x0



に保存されませんでした。したがって、通常、 0x0



へのポインターは特別な状況を表すために使用されました。 たとえば、ポインターを返す関数は、失敗すると0x0



を返すことがあります。 再帰的なデータ構造では、 0x0



を使用して基本的なケースを決定できます(たとえば、現在のツリーノードが葉である、または現在のリンクリストアイテムが最後であるなど)。 これらすべてのために、nullポインターもGoで使用されます。



ただし、nullポインターの使用は安全ではありません。 実際、このポインターは型システムの違反です。 実際にはまったくタイプではないタイプのインスタンスを作成できます。 プログラマがゼロへのポインタをチェックするのを忘れることは非常に一般的であり、これは潜在的にクラッシュにつながり、さらにひどい場合には脆弱性につながります。 ヌルポインターは受け入れられた型システムからノックアウトされるため、コンパイラーはこれを単に受け入れて保護することはできません。



Goの功績として、関数が失敗する可能性がある場合は、Goが採用する値の複数のリターンのメカニズムを強化し、2番目の「失敗した」値を返すのが一般的です。 ただし、このメカニズムは簡単に無視したり、誤用したりする可能性があり、原則として、再帰的なデータ構造の基本的なケースを表すのには役に立ちません。



適切なアプローチ:代数的データ型とタイプセーフな故障モード


型システムに対する暴力で誤った状態や基本的なケースを表す代わりに、型システムを使用してこれらの状況をすべて安全に隠すことができます。



リンクリストを実装するとします。 考えられる2つのケースを示します。1つは、リストの最後にまだ到達しておらず、現在の要素にデータがある場合、2つ目は、リストの最後に到達した場合です。 タイプセーフパスには、これらのケースの1つをそれぞれ表す2つのタイプの実装と、代数的データタイプを使用したその後の組み合わせが含まれます。 何らかのデータを持つリンクリストの要素を表すCons



型と、リストの末尾を表すEnd



型があるとします。 次のように記述できます。



(錆)



 enum List<T> { Cons(T, Box<List<T>>), End } let my_list = Cons(1, box Cons(2, box Cons(3, box End)));
      
      





(ハスケル)



 data List t = End | Cons t (List t) let my_list = Cons 1 (Cons 2 (Cons 3 End))
      
      





各タイプは、データ構造に対して操作を実行する再帰アルゴリズムのベースケース( End



)を定義します。 RustもHaskellもnullポインタを許可しないため、nullポインタに関連するバグに遭遇することは絶対にありません(もちろん、クレイジーな低レベルのものを実行するまで)。



これらの代数的データ型を使用すると、以下に示すパターンマッチングなどの言語機能のおかげで、信じられないほど短い(したがって、バグが発生しやすい)コードを記述できます。



さて、関数が何らかのタイプの値を返すことができるか返さない場合、またはデータ構造にデータが含まれる場合と含まれない場合はどうすればよいでしょうか? つまり、型システムでエラー状態を非表示にするにはどうすればよいですか? この問題を解決するために、RustにはOptionと呼ばれるものがあり、HaskellにはMaybeと呼ばれるものがあります。



空でない行の配列で'H'



で始まる行を探し、最初に一致する行を返す関数、またはそのような行が見つからない場合はエラーを返す関数を想像してください。 Goでは、エラーが発生した場合、 nil



を返す必要があります。 そして、RustとHaskellのポインターで松葉杖を使わずに安全に行う方法を次に示します。



(錆):



 fn search<'a>(strings: &'a[String]) -> Option<&'a str> { for string in strings.iter() { if string.as_slice()[0] == 'H' as u8 { return Some(string.as_slice()); } } None }
      
      





(ハスケル):



 search [] = Nothing search (x:xs) = if (head x) == 'H' then Just x else search xs
      
      





文字列またはnullポインタを返す代わりに、文字列を含む場合と含まない場合があるオブジェクトを返します。 nullポインターを返すことはありません。また、 search()



を使用search()



開発者は、関数の型がそれを示しているため、関数が成功または失敗する可能性があり、両方のケースに対応する必要があることを知っています。 さようならヌルポインターバグ!



型推論



問題の本質


プログラムコード内の各変数の型を示すのは少し時代遅れになります。 値のタイプが明らかな場合があります。



 int x = 5 y = x*2
      
      





y



int



型であることは明らかです。 もちろん、より複雑な状況もあります。たとえば、パラメーターの型に基づいて関数から返される型の結論(またはその逆)などです。



適切なアプローチ:一般的な型推論


RustとHaskellはどちらもHindley-Milner型システムに基づいているため、どちらも型推論に非常に優れており、これらのクールなことができます。



(ハスケル):



 map :: (a -> b) -> [a] -> [b] let doubleNums nums = map (*2) nums doubleNums :: Num t => [t] -> [t]
      
      





関数(*2)



Num



型のパラメーターを受け取り、 Num



型の値を返すため、Haskellはb



型とb



型がNum



a



と判断でき、これから関数がNum



型の値のリストを受け入れて返すと推定できます。 これは、GoやC ++などの言語でサポートされている単純な型推論システムよりもはるかに強力です。 これにより、明示的な型指示の最小数を作成でき、非常に複雑なプログラムであっても、コンパイラは他のすべてを正しく決定できます。



Goアプローチ:ステートメント:=





Goは、次のように機能する代入演算子:=



サポートしています。



(行く):



 foo := bar()
      
      





それはすべて、 bar()



関数によって返された型を出力し、 foo



に同じ値を割り当てます。 こことほぼ同じです。



(C ++):



 auto foo = bar();
      
      





これはあまり面白くない。 それが行うことは、 bar()



関数によって返される型を見るための2秒の努力をなくし、変数型名foo



いくつかの文字を書くことからです。



状態不変性



問題の本質


状態の不変性(不変性)はアイデアであり、その本質は、値が作成時に一度だけ設定され、その後変更できないことです。 このアプローチの利点は非常に明白です。値が変更されていない場合、別の場所で使用するときに、ある場所でのデータ構造の変更に起因するバグの可能性が大幅に減少します。



状態の不変性は、いくつかのタイプの最適化にも役立ちます。



正しいアプローチ:デフォルトの状態の不変性


プログラマは、できるだけ頻繁に不変のデータ構造を使用するようにしてください。 状態の不変性により、起こりうる副作用やプログラムのセキュリティをはるかに簡単に判断できるため、起こりうるエラーのクラス全体が排除されます。



Haskellでは、すべての値は不変です。 データ構造の状態を変更する場合は、目的の値で別の構造を作成する必要があります。 Haskellは遅延計算永続的なデータ構造を使用するため、これは依然として高速です。 Rustはシステムプログラミング言語であるため、遅延計算を使用できず、状態の不変性がHaskellほど実用的であるとは限りません。 それにもかかわらず、Rustでは、宣言された変数はすべてデフォルトで変更されません[この場合、変数と呼ぶことは完全に正しいですが、オリジナルではそうでした-約。 翻訳者]、ただし、必要に応じて状態を変更する機能もあります。 そして、これはすばらしいです。なぜなら、宣言された変数が可変でなければならないことをプログラマに明示的に指示するからです。これは、プログラミングのベストプラクティスの適用に貢献し、コンパイラによる最適化の向上を可能にします。



進入アプローチ:なし


Goはステートレスをサポートしていません。



制御構造



問題の本質


制御構造[元 「制御フロー構造」-約 翻訳者]-プログラミング言語とマシンコードを区別するものの一部。 抽象化を使用して、プログラムの実行を正しい方向に制御できます。 明らかに、すべてのプログラミング言語は、ある種の制御構造をサポートしています。そうでなければ、誰もそれらを使用しません。 ただし、Goで実際に見逃している制御構造がいくつかあります。



適切なアプローチ:パターンマッチングと複合式


パターンマッチングは、データ構造と値を操作するための本当にクールな方法です。 ステロイドのcase



/ switch



に似ています。 次のようにサンプルと比較できます。



(錆):



 match x { 0 | 1 => action_1(), 2 .. 9 => action_2(), _ => action_3() };
      
      





この場合、同様の方法で構造を操作できます。



 deg_kelvin = match temperature { Celsius(t) => t + 273.15, Fahrenheit(t) => (t - 32) / 1.8 + 273.15 };
      
      





前の例は、「複合式」と呼ばれるものを示しています[orig。 「複合表現」-約 翻訳者]。 CやGoのような言語では、 if



およびcase



/ switch



単にプログラムの流れを指示します。 値を計算しません。 RustやHaskellなどの言語では、 if



とパターンマッチングにより、何かに割り当てることができる値を計算できます。 つまり、 if



とパターンマッチングは実際に値を「返す」ことができます。 if



例を次に示します。



(ハスケル):



 x = if (y == "foo") then 1 else 2
      
      





Goアプローチ:意味のないCスタイルのステートメント


Goを軽視したくない-並列化のselect



など、特定のものに適した制御構造がありselect



ただし、私が大好きな複合表現やパターンマッチングはありません。Goは、x := 5



またはなどのアトミック式の割り当てのみをサポートしx := foo()



ます。



組み込みプログラミング



組み込みシステム用のプログラムを作成することは、フル機能のオペレーティングシステムでプログラムを作成することと大きく異なります。一部の言語は、組み込みシステムの特別なプログラミング要件を処理するのにより適しています。



Goをプログラミングロボットの言語として提供している人々がかなりいることに驚いています。Goは、いくつかの理由により、組み込みシステムのプログラミングには適していません。このセクションは、囲izingを批判するためのものではありません。囲Goはこのために設計されたものではありません。このセクションでは、組み込みシステム向けにGoでの作成を推奨する人々の誤解について説明します。



問題#1:ヒープ動的メモリ割り当て


ヒープは、実行時に作成される任意の数のオブジェクトを格納するために使用できるメモリです。ヒープの使用は、動的メモリ割り当てと呼ばれます。



原則として、組み込みシステム用にプログラミングするときに束を使用するのは不合理です。主な理由は、原則として、ヒープにはかなりの追加メモリオーバーヘッドといくつかの非常に複雑な制御構造が必要であるためです。



それでも、もちろん、リアルタイムシステムでヒープを使用するのは理不尽です。 (, , ), , , . , , , , , .



動的メモリ割り当てには、組み込みシステムの効率的なプログラミングに使用を不適切にする他の側面があります。たとえば、ヒープを使用する多くの言語は、ガベージコレクターに依存しています。ガベージコレクターは、実行中にプログラムを一時停止して、使用されなくなったオブジェクトを見つけて削除します。これにより、プログラムは単なる動的メモリを使用する場合よりもさらに予測不能になります。



適切なアプローチ:動的メモリをオプションにする


Rust — , boxes



. , , . Rust .



Go:


Go . Go , Go — , , Go .



Go . . , : Go , . : , , Go — , - - .



Haskellのような言語の場合にも、同様の問題が発生します。これは、リアルタイムのタスクや、ヒープの同等の大きなつながりのために組み込みシステムのプログラミングには不向きです。ただし、Haskellをロボットプログラミング言語として提唱する人を見たことはないので、これについて議論する必要はありません。



問題2:安全でない低レベルコードの記述


組み込みシステム用のプログラムを作成する必要がある場合、安全でないコード(安全でないキャストまたはアドレス演算の使用)の作成を避けることはほとんど不可能です。CまたはC ++では、安全でないことを行うのは非常に簡単です。0xFF



アドレス0x1234



書き込むことでLEDをオンにする必要があると仮定すると、次のことができます:



(C / C ++):



 * (uint8_t *) 0x1234 = 0xFF;
      
      





これは非常に危険であり、非常に低レベルのシステムプログラミングでのみ意味をなすので、GoもHaskellも簡単に実行できません。これらはシステムプログラミング言語ではありません。



適切なアプローチ:安全でないコードの分離


Rustは、セキュリティとシステムプログラミングの両方を指向しており、ツールに安全なコードブロック[orig。「安全でないコードブロック」-約 翻訳者]、危険なコードを安全なコードから明示的に分離する良い方法。Rust言語の0xFF



住所0x1234



レコードがある同じ例を次に示します:



(Rust):



 unsafe{ * (0x1234 as * mut u8) = 0xFF; }
      
      





安全でないコードのブロックの外側でこれを行おうとすると、コンパイラは大声で怒ります。これにより、組み込みシステムのプログラミングに固有の、喜びはないが必要な危険な操作をすべて実行しながら、コードのセキュリティを最大限に維持できます。



進入アプローチ:なし


Goはそのようなものに合わせて調整されておらず、それらに対する組み込みのサポートもありません。



TL; DR



あなたは今でも言うことができます、「なぜGoは良くないのですか?これは苦情のリストです。どの言語でも文句を言うことができます!」本当です。完璧な言語はありません。しかし、私のしつこさはまだそれを少し示したことを願っています:






All Articles