チーム開発の基礎としての不変性と信頼の幻想

一般的に私はC ++プログラマです。 まあそれは起こった。 私のキャリアで書いた商用コードの大部分はC ++です。 私は、ある言語に対する個人的な経験のこのような強い偏りがあまり好きではありません。別の言語で何かを書く機会を逃さないようにしています。 そして、私の現在の雇用主は突然そのような機会を提供しました。私は、Javaで最も些細なユーティリティではないものを作ることを約束しました。 実装言語の選択は歴史的な理由で行われましたが、私は気にしませんでした。 JavaだからJava、私にはあまり馴染みがない-良い。



とりわけ、論理的に関連する特定のデータのセットを作成して特定のコンシューマーに転送するという、かなり単純なタスクがありました。 いくつかのコンシューマーが存在する可能性があり、カプセル化の原則によれば、送信コード(プロデューサー)は、ソースデータで何ができ、ソースデータで何ができるかを知りません。 しかし、製造業者は各消費者が同じデータを受け取る必要があります。 コピーを作ってあげたくありませんでした。 これは、消費者に送信されるデータを変更する機会を何らかの方法で奪う必要があることを意味します。



その時から、Javaでの私の不慣れさが感じられました。 C ++と比較して言語機能が欠けていました。 はい、 final



キーワードがありますが、 final



final Object



const Object*



ではなく、C ++のObject* const



に似ています。 つまり final List<String>



には、たとえば行を追加できます。 それはC ++ビジネスです。マイヤーズの証言に従ってconst



どこにでも配置すること、それだけです! 誰も何も変えません。 だから? まあ、そうでもない。 私は余暇にそのユーティリティを実行する代わりに、これについて少し考えました。それが私がやったことです。



C ++



タスク自体を思い出させてください:



  1. データセットを1回作成します。
  2. 不必要にコピーしないでください。
  3. 消費者がこのデータを変更できないようにします。
  4. コードを最小化、つまり 一般に、ほんの数か所で、必要なデータセットごとに多数のメソッドとインターフェイスを作成しないでください。


マルチスレッド、例外的な意味でのセキュリティなどの悪化する条件はありません。 最も単純なケースを考えてみましょう。 私が最も使い慣れた言語を使用してそれを行う方法は次のとおりです。



foo.hpp
 #pragma once #include <iostream> #include <list> struct Foo { const int intValue; const std::string strValue; const std::list<int> listValue; Foo(int intValue_, const std::string& strValue_, const std::list<int>& listValue_) : intValue(intValue_) , strValue(strValue_) , listValue(listValue_) {} }; std::ostream& operator<<(std::ostream& out, const Foo& foo) { out << "INT: " << foo.intValue << "\n"; out << "STRING: " << foo.strValue << "\n"; out << "LIST: ["; for (auto it = foo.listValue.cbegin(); it != foo.listValue.cend(); ++it) { out << (it == foo.listValue.cbegin() ? "" : ", ") << *it; } out << "]\n"; return out; }
      
      







api.hpp
 #pragma once #include "foo.hpp" #include <iostream> class Api { public: const Foo& getFoo() const { return currentFoo; } private: const Foo currentFoo = Foo{42, "Fish", {0, 1, 2, 3}}; };
      
      





main.cpp
 #include "api.hpp" #include "foo.hpp" #include <list> namespace { void goodConsumer(const Foo& foo) { // do nothing wrong with foo } } int main() { { const auto& api = Api(); goodConsumer(api.getFoo()); std::cout << "*** After good consumer ***\n"; std::cout << api.getFoo() << std::endl; } }
      
      







明らかに、ここではすべてが正常であり、データは変更されていません。



おわりに
 *** After good consumer *** INT: 42 STRING: Fish LIST: [0, 1, 2, 3]
      
      





そして誰かが何かを変えようとしたら?



main.cpp
 void stupidConsumer(const Foo& foo) { foo.listValue.push_back(100); }
      
      







はい、コードはコンパイルされません。



エラー
 src/main.cpp: In function 'void {anonymous}::stupidConsumer(const Foo&)': src/main.cpp:16:36: error: passing 'const std::__cxx11::list<int>' as 'this' argument discards qualifiers [-fpermissive] foo.listValue.push_back(100);
      
      







何がおかしいのでしょうか?



これはC ++です-自分の足で撮影するための豊富な武器を備えた言語です! 例:



main.cpp
 void evilConsumer(const Foo& foo) { const_cast<int&>(foo.intValue) = 7; const_cast<std::string&>(foo.strValue) = "James Bond"; }
      
      







まあ、実際にはすべて:
 *** After evil consumer *** INT: 7 STRING: James Bond LIST: [0, 1, 2, 3]
      
      







また、この場合にconst_cast



代わりにconst_cast



を使用すると、コンパイルエラーが発生することに注意してください。 ただし、Cスタイルのキャストを使用すると、この焦点を絞ることができます。



はい、そのようなコードは未定義の動作[C ++ 17 10.1.7.1/4]につながる可能性があります。 彼は一般的に疑わしいように見えますが、それは良いことです。 レビュー中にキャッチしやすい。



悪意のあるコードがユーザーの好みの深さまで隠れるのは悪いことですが、それでも動作します:



main.cpp
 void evilSubConsumer(const std::string& value) { const_cast<std::string&>(value) = "Loki"; } void goodSubConsumer(const std::string& value) { evilSubConsumer(value); } void evilCautiousConsumer(const Foo& foo) { const auto& strValue = foo.strValue; goodSubConsumer(strValue); }
      
      







おわりに
 *** After evil but cautious consumer *** INT: 42 STRING: Loki LIST: [0, 1, 2, 3]
      
      







このコンテキストでのC ++の長所と短所



どちらが良いですか:



悪い点:





Java



Javaでは、私が理解しているように、わずかに異なるアプローチが使用されます。 final



として宣言されたプリミティブ型は、C ++と同じ意味で定数です。 Javaのfinal String



は基本的に不変であるため、この場合に必要なのはfinal String



的なfinal String



です。



コレクションは不変のラッパーに配置できます。そのためには、 java.util.Collections



クラスの静的メソッドunmodifiableList



unmodifiableMap



などがあります。 つまり 定数オブジェクトと非定数オブジェクトのインターフェースは同じですが、非定数オブジェクトを変更しようとすると例外がスローされます。



ユーザータイプに関しては、ユーザー自身が不変のラッパーを作成する必要があります。 一般に、Javaのオプションは次のとおりです。



Foo.java
 package foo; import java.util.Collections; import java.util.List; public final class Foo { public final int intValue; public final String strValue; public final List<Integer> listValue; public Foo(final int intValue, final String strValue, final List<Integer> listValue) { this.intValue = intValue; this.strValue = strValue; this.listValue = Collections.unmodifiableList(listValue); } @Override public String toString() { final StringBuilder sb = new StringBuilder(); sb.append("INT: ").append(intValue).append("\n") .append("STRING: ").append(strValue).append("\n") .append("LIST: ").append(listValue.toString()); return sb.toString(); } }
      
      







Api.java
 package api; import foo.Foo; import java.util.Arrays; public final class Api { private final Foo foo = new Foo(42, "Fish", Arrays.asList(0, 1, 2, 3)); public final Foo getFoo() { return foo; } }
      
      







Main.java
 import api.Api; import foo.Foo; public final class Main { private static void goodConsumer(final Foo foo) { // do nothing wrong with foo } public static void main(String[] args) throws Exception { { final Api api = new Api(); goodConsumer(api.getFoo()); System.out.println("*** After good consumer ***"); System.out.println(api.getFoo()); System.out.println(); } } }
      
      







おわりに
 *** After good consumer *** INT: 42 STRING: Fish LIST: [0, 1, 2, 3]
      
      







失敗した変更の試み



たとえば、何かを変更しようとする場合:



Main.java
 private static void stupidConsumer(final Foo foo) { foo.listValue.add(100); }
      
      







このコードはコンパイルされますが、実行時に例外がスローされます。



例外
 Exception in thread "main" java.lang.UnsupportedOperationException at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1056) at Main.stupidConsumer(Main.java:15) at Main.main(Main.java:70)
      
      







成功した試み



そして、悪い方法で? 型からfinal



修飾子を削除する方法はありません。 しかし、Javaにはもっと強力なものがあります-リフレクションです。



Main.java
 import java.lang.reflect.Field; private static void evilConsumer(final Foo foo) throws Exception { final Field intField = Foo.class.getDeclaredField("intValue"); intField.setAccessible(true); intField.set(foo, 7); final Field strField = Foo.class.getDeclaredField("strValue"); strField.setAccessible(true); strField.set(foo, "James Bond"); }
      
      







そして免疫に対する
 *** After evil consumer *** INT: 7 STRING: James Bond LIST: [0, 1, 2, 3]
      
      







このようなコードは、C ++のcosnt_cast



よりもcosnt_cast



見えるため、レビューをキャッチするのがさらに簡単です。 そして、それはまた予測不可能な効果につながる可能性があります (つまり、JavaにはUBがありますか?)。 また、任意に深く隠すこともできます。



これらの予測不可能な影響は、リフレクションを使用してfinal



オブジェクトが変更されたときに、 hashCode()



メソッドによって返される値が同じままである可​​能性があるためです。 同じハッシュを持つ異なるオブジェクトは問題になりませんが、異なるハッシュを持つ同じオブジェクトは悪いです。



このようなJavaでの文字列専用のハッキングの危険性は何ですか( )。ここでの文字列はプールに保存でき、互いに無関係で、同じ文字列だけがプール内の同じ値を示すことができます。 変更されたもの-それらすべてを変更しました。



しかし! JVMはさまざまなセキュリティ設定で実行できます。 すでにデフォルトのSecurity Manager



がアクティブになっているため、上記のすべてのトリックをリフレクションで抑制します。



例外
 $ java -Djava.security.manager -jar bin/main.jar Exception in thread "main" java.security.AccessControlException: access denied ("java.lang.reflect.ReflectPermission" "suppressAccessChecks") at java.base/java.security.AccessControlContext.checkPermission(AccessControlContext.java:472) at java.base/java.security.AccessController.checkPermission(AccessController.java:895) at java.base/java.lang.SecurityManager.checkPermission(SecurityManager.java:335) at java.base/java.lang.reflect.AccessibleObject.checkPermission(AccessibleObject.java:85) at java.base/java.lang.reflect.Field.setAccessible(Field.java:169) at Main.evilConsumer(Main.java:20) at Main.main(Main.java:71)
      
      







このコンテキストでのJavaの長所と短所



どちらが良いですか:



悪い点:





Python



まあ、その後、私は単に好奇心の波に流されました。 このようなタスクは、たとえばPythonでどのように解決されますか? そして、彼らはまったく決定されていますか? 実際、Pythonには、そのようなキーワードがなくても、原則として不変はありません。



foo.py
 class Foo(): def __init__(self, int_value, str_value, list_value): self.int_value = int_value self.str_value = str_value self.list_value = list_value def __str__(self): return 'INT: ' + str(self.int_value) + '\n' + \ 'STRING: ' + self.str_value + '\n' + \ 'LIST: ' + str(self.list_value)
      
      







api.py
 from foo import Foo class Api(): def __init__(self): self.__foo = Foo(42, 'Fish', [0, 1, 2, 3]) def get_foo(self): return self.__foo
      
      







main.py
 from api import Api def good_consumer(foo): pass def evil_consumer(foo): foo.int_value = 7 foo.str_value = 'James Bond' def main(): api = Api() good_consumer(api.get_foo()) print("*** After good consumer ***") print(api.get_foo()) print() api = Api() evil_consumer(api.get_foo()) print("*** After evil consumer ***") print(api.get_foo()) print() if __name__ == '__main__': main()
      
      







おわりに
 *** After good consumer *** INT: 42 STRING: Fish LIST: [0, 1, 2, 3] *** After evil consumer *** INT: 7 STRING: James Bond LIST: [0, 1, 2, 3]
      
      







つまり トリックは必要ありません。それを取り、オブジェクトのフィールドを変更します。



紳士協定



Pythonでは、次のプラクティスが受け入れられます。



この言語は、「プライベート」フィールドのマングリングも行います。 非常に素朴な装飾で、C ++との比較はありませんが、これは意図しない(または素朴な)エラーを無視する(キャッチしない)のに十分です。



コード
 class Foo(): def __init__(self, int_value): self.__int_value = int_value def int_value(self): return self.__int_value def evil_consumer(foo): foo.__int_value = 7
      
      







おわりに
 *** After evil consumer *** INT: 42
      
      







そして、意図的に間違いを犯すには、いくつかの文字を追加するだけです。



コード
 def evil_consumer(foo): foo._Foo__int_value = 7
      
      







おわりに
 *** After evil consumer *** INT: 7
      
      







別のオプション



Oz N Tiramが提案した解決策が気に入りました。 これは単純なデコレータで、 読み取り専用フィールドを変更しようとすると例外がスローされます。 これは合意された範囲を少し超えています(「メソッドとインターフェイスの束を作成しないでください」)が、繰り返しますが、私はそれが好きでした。



foo.py
 from read_only_properties import read_only_properties @read_only_properties('int_value', 'str_value', 'list_value') class Foo(): def __init__(self, int_value, str_value, list_value): self.int_value = int_value self.str_value = str_value self.list_value = list_value def __str__(self): return 'INT: ' + str(self.int_value) + '\n' + \ 'STRING: ' + self.str_value + '\n' + \ 'LIST: ' + str(self.list_value)
      
      







main.py
 def evil_consumer(foo): foo.int_value = 7 foo.str_value = 'James Bond'
      
      







おわりに
 Traceback (most recent call last): File "src/main.py", line 35, in <module> main() File "src/main.py", line 28, in main evil_consumer(api.get_foo()) File "src/main.py", line 9, in evil_consumer foo.int_value = 7 File "/home/Tmp/python/src/read_only_properties.py", line 15, in __setattr__ raise AttributeError("Can't touch {}".format(name)) AttributeError: Can't touch int_value
      
      







しかし、これは万能薬ではありません。 しかし、少なくとも対応するコードは疑わしいようです。



main.py
 def evil_consumer(foo): foo.__dict__['int_value'] = 7 foo.__dict__['str_value'] = 'James Bond'
      
      







おわりに
 *** After evil consumer *** INT: 7 STRING: James Bond LIST: [0, 1, 2, 3]
      
      







このコンテキストでのPythonの長所と短所



Pythonは非常に悪いように見えますか? いいえ、これは言語のもう1つの哲学です。 通常、これは「 ここではすべて同意する大人です 」というフレーズで表されます( ここではすべて同意する大人です )。 つまり 承認された標準から明確に逸脱する者はいないと想定されています。 コンセプトは定かではありませんが、生きる権利があります。



どちらが良いですか:



悪い点:





行く



私が定期的に感じている別の言語(主に記事を読んでいるだけです)ですが、まだ商用コードを書いていません。 const



キーワードは基本的にそこにありますが、コンパイル時に既知の文字列と整数値(つまり、C ++のconstexpr



)のみが定数になります。 しかし、構造フィールドはできません。 つまり フィールドがオープンであると宣言されている場合、Pythonのようになります-希望する人を変更します。 面白くない。 コードの例も挙げません。



さて、フィールドをプライベートにして、値をopenメソッドの呼び出しで取得できるようにします。 Goでfireを入手できますか? もちろん、ここにも反射があります。



foo.go
 package foo import "fmt" type Foo struct { intValue int strValue string listValue []int } func (foo *Foo) IntValue() int { return foo.intValue; } func (foo *Foo) StrValue() string { return foo.strValue; } func (foo *Foo) ListValue() []int { return foo.listValue; } func (foo *Foo) String() string { result := fmt.Sprintf("INT: %d\nSTRING: %s\nLIST: [", foo.intValue, foo.strValue) for i, num := range foo.listValue { if i > 0 { result += ", " } result += fmt.Sprintf("%d", num) } result += "]" return result } func New(i int, s string, l []int) Foo { return Foo{intValue: i, strValue: s, listValue: l} }
      
      







api.go
 package api import "foo" type Api struct { foo foo.Foo } func (api *Api) GetFoo() *foo.Foo { return &api.foo } func New() Api { api := Api{} api.foo = foo.New(42, "Fish", []int{0, 1, 2, 3}) return api }
      
      







main.go
 package main import ( "api" "foo" "fmt" "reflect" "unsafe" ) func goodConsumer(foo *foo.Foo) { // do nothing wrong with foo } func evilConsumer(foo *foo.Foo) { reflectValue := reflect.Indirect(reflect.ValueOf(foo)) member := reflectValue.FieldByName("intValue") intPointer := unsafe.Pointer(member.UnsafeAddr()) realIntPointer := (*int)(intPointer) *realIntPointer = 7 member = reflectValue.FieldByName("strValue") strPointer := unsafe.Pointer(member.UnsafeAddr()) realStrPointer := (*string)(strPointer) *realStrPointer = "James Bond" } func main() { apiInstance := api.New() goodConsumer(apiInstance.GetFoo()) fmt.Println("*** After good consumer ***") fmt.Println(apiInstance.GetFoo().String()) fmt.Println() apiInstance = api.New() evilConsumer(apiInstance.GetFoo()) fmt.Println("*** After evil consumer ***") fmt.Println(apiInstance.GetFoo().String()) }
      
      







おわりに
 *** After good consumer *** INT: 42 STRING: Fish LIST: [0, 1, 2, 3] *** After evil consumer *** INT: 7 STRING: James Bond LIST: [0, 1, 2, 3]
      
      







ところで、Goの文字列はJavaのように不変です。 スライスとマップは可変であり、Javaとは異なり、言語のコアにはそれらを不変にする方法はありません。 コード生成のみ(私が間違っていれば正しい)。 つまり すべてが正しく行われたとしても、ダーティトリックを使用せずに、メソッドからスライスを返すだけです。このスライスはいつでも変更できます。



gopherコミュニティには明らか不変の型がありませんが、Go 1.xには確かに存在しません。



このコンテキストでのGoの長所と短所



Go構造体のフィールドの変更を禁止する可能性についての私の経験の浅い見解では、JavaとPythonの間のどこかにあり、後者に近いものです。 同時に、GoはPythonの大人の原則を満たしていません(私は探していましたが、会いませんでした)。 ただし、1つのパッケージ内ではすべてがすべてにアクセスでき、定数からは基本的なもののみが残り、変更不可能なコレクションが存在しないことを示します。 つまり 開発者が何らかのデータを読み取れる場合、高い確率で何かを書き込むことができます。 これは、Pythonの場合のように、コンパイラーから人にほとんどの責任を伝えます。



どちらが良いですか:



悪い点:





アーラン



これは競争の対象外です。 それでも、Erlangは上記の4つとは非常に異なるパラダイムを持つ言語です。 興味を持って勉強したら、自分を機能的なスタイルで考えさせるのが本当に好きでした。 しかし、残念ながら、これらのスキルの実用的な応用は見つかりませんでした。



そのため、この言語では、変数の値は一度しか割り当てることができません。 そして、関数が呼び出されると、すべての引数は値で渡されます。 それらのコピーが作成されます(ただし、末尾再帰の最適化があります)。



foo.erl
 -module(foo). -export([new/3, print/1]). new(IntValue, StrValue, ListValue) -> {foo, IntValue, StrValue, ListValue}. print(Foo) -> case Foo of {foo, IntValue, StrValue, ListValue} -> io:format("INT: ~w~nSTRING: ~s~nLIST: ~w~n", [IntValue, StrValue, ListValue]); _ -> throw({error, "Not a foo term"}) end.
      
      







api.erl
 -module(api). -export([new/0, get_foo/1]). new() -> {api, foo:new(42, "Fish", [0, 1, 2, 3])}. get_foo(Api) -> case Api of {api, Foo} -> Foo; _ -> throw({error, "Not an api term"}) end.
      
      







main.erl
 -module(main). -export([start/0]). start() -> ApiForGoodConsumer = api:new(), good_consumer(api:get_foo(ApiForGoodConsumer)), io:format("*** After good consumer ***~n"), foo:print(api:get_foo(ApiForGoodConsumer)), io:format("~n"), ApiForEvilConsumer = api:new(), evil_consumer(api:get_foo(ApiForEvilConsumer)), io:format("*** After evil consumer ***~n"), foo:print(api:get_foo(ApiForEvilConsumer)), init:stop(). good_consumer(_) -> done. evil_consumer(Foo) -> _ = setelement(1, Foo, 7), _ = setelement(2, Foo, "James Bond").
      
      







おわりに
 *** After good consumer *** INT: 42 STRING: Fish LIST: [0,1,2,3] *** After evil consumer *** INT: 42 STRING: Fish LIST: [0,1,2,3]
      
      







もちろん、くしゃみごとにコピーを作成できるため、他の言語のデータ破損から身を守ることができます。 しかし、他の方法でそれを単純に行うことのできない言語があります(確かにそうではありません)。



このコンテキストでのErlangの長所と短所



どちらが良いですか:



悪い点:





結論と結論の代わりに



そして、結果は何ですか? まあ、昔読んだ本からほこりを吹き飛ばしたという事実に加えて、私は指を伸ばし、5つの異なる言語で役に立たないプログラムを書き、FACを傷つけましたか?



まず、C ++がアクティブなバカに対する保護の観点から最も信頼できる言語であると考えるのをやめました。 すべての柔軟性と豊富な構文にもかかわらず。 今、私はこの点でJavaがより多くの保護を提供すると考える傾向があります。 これは非常に独創的な結論ではありませんが、私にとっては非常に便利です。



第二に、プログラミング言語は、構文とセマンティクスのレベルで特定のデータへのアクセスを制限しようとする言語と、ユーザーにこれらの懸念を移そうとしない言語とに大まかに分けることができるという考えを突然思いつきました。 。 したがって、エントリのしきい値、ベストプラクティス、チーム開発参加者(技術的および個人的)の要件は、選択した関心のある言語によって何らかの形で異なる必要があります。 このテーマについて読みたいです。



3番目:言語がどのようにデータを書き込みから保護しようとしても、ユーザーは必要に応じてほぼいつでもこれを行うことができます(Erlangのおかげで「ほぼ」)。 そして、あなたが主流の言語にとらわれているなら、それはいつも簡単です。 そして、これらのconst



final



はすべて、推奨事項、つまりインターフェースを正しく使用するための指示にすぎません。 すべての言語がそれを持っているわけではありませんが、私はまだそのようなツールを自分の武器に持つことを好みます。



そして第4に、最も重要なことです。(主流の)言語は開発者が厄介なことを行うことを妨げることができないため、この開発者を維持する唯一のことは彼自身の良識です。そしてconst



、コードを入れるとき、同僚(および私の将来の自分)に何かを禁止するのではなく、彼ら(そして私)が彼らに従うと信じて指示を残していることがわかります。つまりは同僚信頼しています。



いいえ、私は長い間、最新のソフトウェア開発がチーム作業の99.99%であることを知っています。しかし、私は幸運でした、私の同僚はすべて「大人、責任者」でした。私にとっては、常にそうであり、すべてのチームメンバーが確立されたルールを順守することは当然のことです。私たちが絶えずお互いを信頼尊敬しているという認識に至るまでの道のりは長いものでしたが、冷静で安全です。



PS



誰かが使用されたコード例に興味があるなら、あなたはそれらをここで取ることができます



All Articles