不变性和信任感是团队发展的基础

通常,我是C ++程序员。 好吧,发生了。 我在职业生涯中编写的绝大多数商业代码是C ++。 我真的不喜欢我的个人经历对一种语言的强烈偏见,并且我尽量不要错过用另一种语言写东西的机会。 我的现任雇主突然提供了这样一个机会:我承诺要使它成为Java中最简单的实用程序。 选择实施语言是出于历史原因,我并不介意。 Java对Java来说,我越不熟悉-越好。

除其他外,我有一个相当简单的任务:一次形成一组逻辑连接的数据并将其传输给特定的使用者。 可以有多个使用者,并且根据封装原理,传输代码(生产者)不知道内部是什么以及对源数据可以做什么。 但是制造商需要每个消费者接收相同的数据。 我不想复印并给他们。 这意味着我们必须以某种方式使消费者失去改变传输给他们的数据的机会。

那时正是我对Java的缺乏经验。 与C ++相比,我缺少语言功能。 是的,这里有final关键字,但是final Object就像C ++中的Object* const ,而不是const Object* 。 即 例如,在final List<String>您可以添加行。 这是C ++业务:根据Myers的遗嘱将const放在任何地方,就是这样! 没有人会改变任何东西。 那呢 好吧,不是真的。 我考虑了一下, 而不是在闲暇时做那个实用程序 ,这就是我想到的。

C ++


让我提醒您任务本身:

  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] 


我还注意到在这种情况下使用reinterpret_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中,使用的方法略有不同。 声明为final基本类型在C ++中具有相同的含义。 Java中的字符串基本上是不可变的,因此在这种情况下, final String是我们需要的。

可以将集合放置在不可变的包装器中,为此,有java.util.Collections类的静态方法unmodifiableListunmodifiableMap等。 即 常数对象和非常数对象的接口相同,但是非常数对象在尝试更改它们时会引发异常。

至于自定义类型,用户本人将必须创建不可变的包装器。 通常,这是我对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更可疑,更容易引起评论。 而且还会导致不可预期的影响 (即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在这种情况下的优缺点


哪个好:
  • final关键字以某种方式限制了数据更改
  • 有一些库方法可以将集合变成不可变的
  • 代码审查很容易发现有意识的免疫违规
  • 具有JVM安全设置

不好的是:
  • 尝试更改不可变对象只会在运行时出现
  • 为了使某个类的对象不可变,您必须自己编写适当的包装
  • 在没有适当的安全设置的情况下,可以更改任何不可变数据
  • 这项操作可能会带来无法预测的后果(尽管可能很好,几乎没有人会这样做)


巨蟒


好吧,那之后,我只是被好奇的浪潮席卷而来。 例如在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 ++和Java中受保护 )字段和方法
  • 名称以两个下划线开头的自定义字段和方法是私有字段和方法

该语言甚至使“私有”字段受到干扰 。 非常幼稚的修饰,无法与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看起来很糟糕吗? 不,这只是另一种语言哲学。 通常用“ 我们在这里都是成年人 ”这一短语来表达( 我们在这里都是成年人 )。 即 假定没有人会特别偏离公认的规范。 这个概念尚不确定,但它具有生命权。

哪个好:
  • 公开声明程序员应该监视访问权限,而不是编译器或解释器
  • 对于安全和私有字段和方法,存在公认的命名约定
  • 在代码审查中很容易检测到某些访问冲突

不好的是:
  • 在语言级别,不可能限制对班级领域的访问
  • 一切都取决于开发商的诚意和诚实
  • 错误仅在运行时发生


去吧


我还经常感觉到的另一种语言(主要是阅读文章),尽管我尚未在其上编写任何商业代码。 const关键字基本上在那里,但是只有在编译时已知的字符串和整数值(即C ++中的constexpr )才可以是常量。 但是结构字段不能。 即 如果这些字段被声明为open,那么结果就像在python中一样-更改您想要的人。 没意思 我什至不提供示例代码。

好吧,让这些字段是私有的,让它们的值通过调用open方法获得。 我可以在Go中得到柴火吗? 当然,这里也有反思。

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原则。 但有一个问题:在一个程序包中,所有内容都可以访问所有内容,常量中只有基本条件,缺少不变的集合。 即 如果开发人员可以读取一些数据,那么他很有可能可以在那里写一些东西。 就像python一样,它将大部分责任从编译器传达给人员。

哪个好:
  • 所有访问错误均在编译期间发生
  • 在评论中清晰可见基于反射的肮脏技巧

不好的是:
  • 根本没有概念
  • 不可能限制对包内结构域的访问
  • 为了保护字段不受软件包外部的更改,您将必须编写getter
  • 所有参考集合都是可变的
  • 借助反思,您甚至可以更改私有字段


Erlang


这是出于竞争。 尽管如此,Erlang是一种与上述四种语言截然不同的语言。 一旦我对它很有兴趣地学习了,我真的很喜欢让自己以实用的方式思考。 但是,不幸的是,我没有找到这些技能的实际应用。

因此,用这种语言只能将变量的值分配一次。 当调用函数时,所有参数都按值传递,即 复制了它们(但是对尾递归进行了优化)。

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. 


主文件
 -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种不同的语言编写了一个无用的程序,并划掉了FAQ。

首先,我不再认为C ++是防止主动傻瓜的最可靠的语言。 尽管具有灵活性和丰富的语法。 现在,我倾向于认为Java在这方面提供了更多的保护。 这不是一个很原始的结论,但对我自己来说,它非常有用。

其次,我突然为自己提出了这样的想法:可以将编程语言大致分为试图在语法和语义级别限制访问某些数据的语言,以及甚至不试图将这些问题转移给用户的语言。 。 因此,团队开发参与者(技术人员和个人)的入职门槛,最佳实践和要求应根据所选择的感兴趣语言而有所不同。 我很想读这个主题。

第三:无论该语言如何尝试保护数据免于写入,如果需要,用户几乎总是可以这样做(由于Erlang,“几乎”)。 而且,如果您只使用主流语言-这总是很容易的。 事实证明,所有这些constfinal都不过是建议,即正确使用接口的说明。 并非所有语言都具有它,但我仍然更喜欢在我的武器库中使用这种工具。

-, : () , , — . , , const , - ( ), , , ( ) . 即相信我的同事们。

不,我早就知道现代软件开发占团队合作的99.99%。但是我很幸运,我的所有同事都是“成人,有责任感”的人。对我而言,总会发生某种情况,所有团队成员都必须遵守既定规则,这是理所当然的。我认识到我们一直相互信任相互尊重的道路是漫长而又该死的镇定与安全。

聚苯乙烯


如果有人对所使用的代码示例感兴趣,可以在此处获取

Source: https://habr.com/ru/post/zh-CN447478/


All Articles