JS開発者として、これが私を夜更かしするものです

公開: 2022-03-11

JavaScriptは言語の奇妙なものです。 Smalltalkに触発されていますが、Cのような構文を使用しています。 これは、手続き型、関数型、およびオブジェクト指向プログラミング(OOP)パラダイムの側面を組み合わせたものです。 それは、考えられるほとんどすべてのプログラミング問題を解決するための多くの、しばしば冗長なアプローチを持っており、どれが好ましいかについて強く意見されていません。 それは弱く動的に型付けされており、経験豊富な開発者でさえもつまずく型強制への迷路のようなアプローチを備えています。

JavaScriptには、いぼ、トラップ、および疑わしい機能もあります。 新しいプログラマーは、非同期性、クロージャ、巻き上げなど、より難しい概念のいくつかに苦労しています。 他の言語の経験を持つプログラマーは、名前と外観が似ているものはJavaScriptでも同じように機能し、多くの場合間違っていると合理的に想定しています。 配列は実際には配列ではありません。 thisとの取引は何ですか、プロトタイプは何ですか、そしてnewは実際に何をしますか?

ES6クラスのトラブル

最悪の犯罪者は、JavaScriptの最新リリースバージョンであるECMAScript 6(ES6): classesの新機能です。 クラスに関する話のいくつかは率直に言って憂慮すべきものであり、言語が実際にどのように機能するかについての根深い誤解を明らかにしています。

「JavaScriptはクラスを持っているので、ついに本当のオブジェクト指向言語になりました!」

または:

「クラスは、JavaScriptの壊れた継承モデルについて考えることから私たちを解放します。」

あるいは:

「クラスは、JavaScriptで型を作成するためのより安全で簡単なアプローチです。」

これらのステートメントは、典型的な継承に何か問題があることを示唆しているため、私を悩ませることはありません。 それらの議論を脇に置いておきましょう。 これらのステートメントはどれも真実ではないため、私を悩ませます。また、JavaScriptの「すべての人のための」アプローチの結果を示しています。これは、プログラマーが言語を理解するのに、それが可能にするよりも頻繁に機能しなくなります。 先に進む前に、説明しましょう。

JavaScriptポップクイズ#1:これらのコードブロックの本質的な違いは何ですか?

 function PrototypicalGreeting(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } PrototypicalGreeting.prototype.greet = function() { return `${this.greeting}, ${this.name}!` } const greetProto = new PrototypicalGreeting("Hey", "folks") console.log(greetProto.greet())
 class ClassicalGreeting { constructor(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } greet() { return `${this.greeting}, ${this.name}!` } } const classyGreeting = new ClassicalGreeting("Hey", "folks") console.log(classyGreeting.greet())

ここでの答えは、1つではないということです。 これらは事実上同じことをします、それはES6クラス構文が使われたかどうかの問題だけです。

確かに、2番目の例はより表現力豊かです。 その理由だけで、あなたはclassが言語への素晴らしい追加であると主張するかもしれません。 残念ながら、問題はもう少し微妙です。

JavaScriptポップクイズ#2:次のコードは何をしますか?

 function Proto() { this.name = 'Proto' return this; } Proto.prototype.getName = function() { return this.name } class MyClass extends Proto { constructor() { super() this.name = 'MyClass' } } const instance = new MyClass() console.log(instance.getName()) Proto.prototype.getName = function() { return 'Overridden in Proto' } console.log(instance.getName()) MyClass.prototype.getName = function() { return 'Overridden in MyClass' } console.log(instance.getName()) instance.getName = function() { return 'Overridden in instance' } console.log(instance.getName())

正解は、コンソールに出力することです。

 > MyClass > Overridden in Proto > Overridden in MyClass > Overridden in instance

間違って答えると、実際classがわかりません。 これはあなたのせいではありません。 Arrayと同じように、 classは言語機能ではなく、構文上の不明瞭さです。 これは、典型的な継承モデルとそれに付随する不器用なイディオムを隠そうとします。これは、JavaScriptがそうではないことを実行していることを意味します。

Javaのような言語から来た古典的なOOP開発者がES6クラス継承モデルをより快適にするために、 classがJavaScriptに導入されたと言われたかもしれません。 あなたそれらの開発者の一人であるなら、その例は恐らくあなたを怖がらせたでしょう。 そうすべき。 これは、JavaScriptのclassキーワードには、クラスが提供することを意図した保証が含まれていないことを示しています。 また、プロトタイプ継承モデルの主な違いの1つを示しています。プロトタイプはオブジェクトインスタンスであり、タイプではありません。

プロトタイプとクラス

クラスベースの継承とプロトタイプベースの継承の最も重要な違いは、クラスは実行時にインスタンス化できるを定義するのに対し、プロトタイプ自体はオブジェクトインスタンスであるということです。

ES6クラスの子は、親を新しいプロパティとメソッドで拡張する別の定義であり、実行時にインスタンス化できます。 プロトタイプの子は、子に実装されていないプロパティを親に委任する別のオブジェクトインスタンスです。

補足:なぜ私がクラスメソッドについて言及したのに、プロトタイプメソッドについては言及しなかったのか疑問に思われるかもしれません。 これは、JavaScriptにメソッドの概念がないためです。 関数はJavaScriptのファーストクラスであり、プロパティを持つことも、他のオブジェクトのプロパティにすることもできます。

クラスコンストラクターは、クラスのインスタンスを作成します。 JavaScriptのコンストラクターは、オブジェクトを返す単なる古い関数です。 JavaScriptコンストラクターの唯一の特別な点は、 newキーワードを指定して呼び出されると、そのプロトタイプが返されたオブジェクトのプロトタイプとして割り当てられることです。 それが少し紛らわしいように聞こえるなら、あなたは一人ではありません—それはそうです、そしてそれはプロトタイプがよく理解されていない理由の大きな部分です。

非常に細かい点を言えば、プロトタイプの子はそのプロトタイプのコピーではなく、そのプロトタイプと同じ形状のオブジェクトでもありません。 子にはプロトタイプの生きた参照があり、子に存在しないプロトタイププロパティは、プロトタイプ上の同じ名前のプロパティへの一方向の参照です。

次のことを考慮してください。

 let parent = { foo: 'foo' } let child = { } Object.setPrototypeOf(child, parent) console.log(child.foo) // 'foo' child.foo = 'bar' console.log(child.foo) // 'bar' console.log(parent.foo) // 'foo' delete child.foo console.log(child.foo) // 'foo' parent.foo = 'baz' console.log(child.foo) // 'baz'
注:実際には、このようなコードを作成することはほとんどありません。これはひどい慣習ですが、原則を簡潔に示しています。

前の例では、 child.fooundefinedでしたが、 parent.fooを参照していました。 childfooを定義するとすぐに、 child.fooの値は'bar'になりましたが、 parent.fooは元の値を保持していました。 delete child.fooと、再びparent.fooが参照されます。つまり、親の値を変更すると、 child.fooは新しい値を参照します。

何が起こったのかを見てみましょう(わかりやすくするために、これらは文字Stringsであり、文字列リテラルではないふりをします。違いはここでは重要ではありません):

プロトタイプチェーンをウォークスルーして、JavaScriptで欠落している参照がどのように処理されるかを示します。

これが内部でどのように機能するか、特にnewthisの特性は別の日のトピックですが、詳細を知りたい場合は、MozillaにJavaScriptのプロトタイプ継承チェーンに関する詳細な記事があります。

重要なポイントは、プロトタイプはtypeを定義しないということです。 それらはそれ自体がinstancesであり、実行時に変更可能であり、すべてが暗示および含意します。

まだ私と一緒に? JavaScriptクラスの分析に戻りましょう。

JavaScriptポップクイズ#3:クラスでプライバシーをどのように実装しますか?

上記のプロトタイプとクラスのプロパティは、「不安定に窓の外にぶら下がっている」ほど「カプセル化」されていません。 それを修正する必要がありますが、どうすればよいですか?

ここにコード例はありません。 答えはあなたができないということです。

JavaScriptにはプライバシーの概念はありませんが、クロージャはあります。

 function SecretiveProto() { const secret = "The Class is a lie!" this.spillTheBeans = function() { console.log(secret) } } const blabbermouth = new SecretiveProto() try { console.log(blabbermouth.secret) } catch(e) { // TypeError: SecretiveClass.secret is not defined } blabbermouth.spillTheBeans() // "The Class is a lie!"

何が起こったのか分かりますか? そうでなければ、あなたはクロージャを理解していません。 それは大丈夫です、本当に-彼らは彼らがそうであるように作られているほど威圧的ではなく、彼らは非常に便利です、そしてあなたはそれらについて学ぶために少し時間をとるべきです。

JavaScriptポップクイズ#4: classキーワードを使用した上記と同等のものは何ですか?

申し訳ありませんが、これは別のトリックの質問です。 基本的に同じことができますが、次のようになります。

 class SecretiveClass { constructor() { const secret = "I am a lie!" this.spillTheBeans = function() { console.log(secret) } } looseLips() { console.log(secret) } } const liar = new SecretiveClass() try { console.log(liar.secret) } catch(e) { console.log(e) // TypeError: SecretiveClass.secret is not defined } liar.spillTheBeans() // "I am a lie!"

それがSecretiveProtoよりも簡単または明確に見えるかどうかを教えてください。 私の個人的な見解では、それはやや悪いです。JavaScriptでのclass宣言の慣用的な使用を破り、たとえばJavaからの期待どおりには機能しません。 これは、次のことで明らかになります。

JavaScriptポップクイズ#5: SecretiveClass::looseLips()は何をしますか?

確認してみましょう:

 try { liar.looseLips() } catch(e) { // ReferenceError: secret is not defined }

ええと…それは厄介でした。

JavaScriptポップクイズ#6:経験豊富なJavaScript開発者はどちらを好みますか?プロトタイプまたはクラス?

ご想像のとおり、これはもう1つのトリックの質問です。経験豊富なJavaScript開発者は、可能な場合は両方を避ける傾向があります。 慣用的なJavaScriptを使用して上記を行うための優れた方法は次のとおりです。

 function secretFactory() { const secret = "Favor composition over inheritance, `new` is considered harmful, and the end is near!" const spillTheBeans = () => console.log(secret) return { spillTheBeans } } const leaker = secretFactory() leaker.spillTheBeans()

これは、継承の固有の醜さを回避したり、カプセル化を強制したりするだけではありません。 プロトタイプやクラスでは簡単にできない、 secretFactoryleakerで他に何ができるか考えてみてください。

一つには、 thisのコンテキストについて心配する必要がないので、それを分解することができます:

 const { spillTheBeans } = secretFactory() spillTheBeans() // Favor composition over inheritance, (...)

それはかなりいいです。 newthisおかしなことを避けることに加えて、それは私たちのオブジェクトをCommonJSおよびES6モジュールと交換可能に使用することを可能にします。 また、構成が少し簡単になります。

 function spyFactory(infiltrationTarget) { return { exfiltrate: infiltrationTarget.spillTheBeans } } const blackHat = spyFactory(leaker) blackHat.exfiltrate() // Favor composition over inheritance, (...) console.log(blackHat.infiltrationTarget) // undefined (looks like we got away with it)

exfiltrateのクライアントは、 blackHatがどこから来たのかを心配する必要はなく、 spyFactoryFunction::bindコンテキストジャグリングや深くネストされたプロパティをいじくり回す必要はありません。 念のために言っておきますが、単純な同期手続き型コードではthisについてあまり心配する必要はありませんが、非同期コードではあらゆる種類の問題が発生するため、回避したほうがよいでしょう。

少し考えれば、 spyFactoryは、あらゆる種類の侵入ターゲット、つまりファサードを処理できる高度なスパイツールに発展する可能性があります。

もちろん、クラスでも、あるいはクラスの品揃えでもそれを行うことができます。これらはすべて、 abstract classまたはinterfaceから継承します。ただし、JavaScriptには抽象またはインターフェースの概念がありません。

グリーターの例に戻って、ファクトリでどのように実装するかを見てみましょう。

 function greeterFactory(greeting = "Hello", name = "World") { return { greet: () => `${greeting}, ${name}!` } } console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!

これらの工場は、私たちが進むにつれてますます簡潔になっていることに気づいたかもしれませんが、心配しないでください。同じことをしています。 補助輪が外れています、皆さん!

これは、同じコードのプロトタイプまたはクラスバージョンよりもすでに定型的ではありません。 第二に、それはその特性のカプセル化をより効果的に達成します。 また、メモリとパフォーマンスのフットプリントが低い場合もあります(一見すると見えないかもしれませんが、JITコンパイラは、重複を減らして型を推測するために、舞台裏で静かに動作しています)。

したがって、より安全で、多くの場合、より高速であり、このようなコードを作成する方が簡単です。 なぜクラスが再び必要なのですか? もちろん、再利用性。 不幸で熱狂的なグリーターバリアントが必要な場合はどうなりますか? そうですね、 ClassicalGreetingクラスを使用している場合は、おそらくクラス階層の夢に直接飛び込むでしょう。 句読点をパラメータ化する必要があることがわかっているので、少しリファクタリングして子を追加します。

 // Greeting class class ClassicalGreeting { constructor(greeting = "Hello", name = "World", punctuation = "!") { this.greeting = greeting this.name = name this.punctuation = punctuation } greet() { return `${this.greeting}, ${this.name}${this.punctuation}` } } // An unhappy greeting class UnhappyGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, " :(") } } const classyUnhappyGreeting = new UnhappyGreeting("Hello", "everyone") console.log(classyUnhappyGreeting.greet()) // Hello, everyone :( // An enthusiastic greeting class EnthusiasticGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, "!!") } greet() { return super.greet().toUpperCase() } } const greetingWithEnthusiasm = new EnthusiasticGreeting() console.log(greetingWithEnthusiasm.greet()) // HELLO, WORLD!!

誰かがやって来て、階層に完全に適合しない機能を要求し、すべてが意味をなさなくなるまでは、これは優れたアプローチです。 工場で同じ機能を書き込もうとしている間、その考えにピンを入れてください。

 const greeterFactory = (greeting = "Hello", name = "World", punctuation = "!") => ({ greet: () => `${greeting}, ${name}${punctuation}` }) // Makes a greeter unhappy const unhappy = (greeter) => (greeting, name) => greeter(greeting, name, ":(") console.log(unhappy(greeterFactory)("Hello", "everyone").greet()) // Hello, everyone :( // Makes a greeter enthusiastic const enthusiastic = (greeter) => (greeting, name) => ({ greet: () => greeter(greeting, name, "!!").greet().toUpperCase() }) console.log(enthusiastic(greeterFactory)().greet()) // HELLO, WORLD!!

少し短いですが、このコードが優れているかどうかは明らかではありません。 実際、読みにくいと主張することもできますが、これは鈍いアプローチかもしれません。 unhappyGreeterFactoryenthusiasticGreeterFactoryだけではありませんか?

それからあなたのクライアントがやって来て、「私は不幸で、部屋全体にそれについて知ってもらいたい新しいグリーターが必要です!」と言います。

 console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(

この熱狂的に不幸なグリーターを複数回使用する必要がある場合は、自分で簡単にすることができます。

 const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory)) console.log(aggressiveGreeterFactory("You're late", "Jim").greet())

プロトタイプまたはクラスで機能するこのスタイルの構成へのアプローチがあります。 たとえば、 UnhappyGreetingEnthusiasticGreetingをデコレータとして再考することができます。 それでも、上記で使用した機能スタイルのアプローチよりも多くの定型文が必要になりますが、それは実際のクラスの安全性とカプセル化に支払う代償です。

問題は、JavaScriptでは、その自動安全性が得られないということです。 class使用法を強調するJavaScriptフレームワークは、これらの種類の問題を解決するために多くの「魔法」を実行し、クラスを強制的に動作させます。 いつかPolymerのElementMixinソースコードを見てください。あえて。 これはJavaScriptアルカナのアーチウィザードレベルであり、皮肉や皮肉がないことを意味します。

もちろん、 Object.freezeまたはObject.definePropertiesを使用して上記で説明した問題のいくつかを修正して、効果を増減させることができます。 しかし、JavaScriptネイティブに提供するツールを無視しながら、関数なしでフォームを模倣するのはなぜですか?Javaのような言語では見つからない可能性があります。 ツールボックスのすぐ隣に実際のドライバーが置かれているときに、「ドライバー」というラベルの付いたハンマーを使用してネジを締めますか?

良い部品を見つける

JavaScript開発者は、口語的にも同じ名前の本を参照しても、言語の優れた部分を強調することがよくあります。 私たちは、より疑わしい言語設計の選択によって設定された罠を避け、クリーンで読みやすく、エラーを最小限に抑え、再利用可能なコードを記述できるようにする部分に固執するようにしています。

JavaScriptのどの部分が適格であるかについては合理的な議論がありますが、 classはそれらの1つではないことを確信できたと思います。 それができない場合は、JavaScriptでの継承が混乱を招く可能性があり、そのclassがそれを修正したり、プロトタイプを理解する必要をなくしたりしないことを理解していただければ幸いです。 オブジェクト指向のデザインパターンがクラスやES6の継承なしで正常に機能するというヒントを理解した場合は、追加のクレジットが必要です。

classを完全に避けるように言っているのではありません。 継承が必要な場合があり、 classはそれを行うためのよりクリーンな構文を提供します。 特に、 class X extends Yを拡張し、古いプロトタイプのアプローチよりもはるかに優れています。 それに加えて、多くの人気のあるフロントエンドフレームワークはその使用を奨励しており、原則として奇妙な非標準コードを書くことはおそらく避けるべきです。 私はこれがどこに向かっているのか気に入らない。

私の悪夢では、JavaScriptライブラリの全世代がclassを使用して記述されており、他の一般的な言語と同様に動作することを期待しています。 まったく新しいクラスのバグ(しゃれを意図したもの)が発見されました。 不注意にclass罠に陥らなければ、奇形のJavaScriptの墓地に簡単に残されていた可能性のある古いものが復活します。 経験豊富なJavaScript開発者は、これらのモンスターに悩まされています。なぜなら、人気のあるものが必ずしも良いものであるとは限らないからです。

最終的に、私たちは皆、欲求不満をあきらめ、Rust、Go、Haskell、または他に何を知っているかで車輪を再発明し始め、次にWeb用のWasmにコンパイルし、新しいWebフレームワークとライブラリが多言語の無限に増殖します。

それは本当に私を夜に保ちます。