A ilusão de imutabilidade e confiança como base do desenvolvimento da equipe

Geralmente sou um programador de C ++. Bem aconteceu. A grande maioria do código comercial que escrevi em minha carreira é C ++. Não gosto muito de um viés tão forte da minha experiência pessoal em relação a um idioma e tento não perder a oportunidade de escrever algo em outro idioma. E meu atual empregador de repente ofereceu essa oportunidade: comprometi-me a tornar um não o utilitário mais trivial em Java. A escolha da linguagem de implementação foi feita por razões históricas, e eu não me importei. Java para Java, quanto menos familiar para mim - melhor.

Entre outras coisas, tive uma tarefa bastante simples: formar um determinado conjunto de dados conectados logicamente e transferi-los para um determinado consumidor. Pode haver vários consumidores e, de acordo com o princípio do encapsulamento, o código de transmissão (produtor) não tem idéia do que está dentro e do que pode fazer com os dados de origem. Mas o fabricante precisa que cada consumidor receba os mesmos dados. Eu não queria fazer cópias e entregá-las. Isso significa que devemos, de alguma forma, privar os consumidores da oportunidade de alterar os dados transmitidos a eles.

Foi então que minha inexperiência em Java se fez sentir. Eu não tinha os recursos de linguagem em comparação com o C ++. Sim, existe a palavra-chave final aqui, mas final Object é como Object* const em C ++, não const Object* . I.e. na final List<String> você pode adicionar linhas, por exemplo. É um negócio de C ++: colocar const todos os lugares de acordo com o testamento de Myers, e é isso! Ninguém vai mudar nada. Então Bem, na verdade não. Pensei um pouco sobre isso, em vez de usar esse utilitário à vontade, e foi para isso que cheguei.

C ++


Deixe-me lembrá-lo da tarefa em si:

  1. Crie um conjunto de dados uma vez.
  2. Não copie nada desnecessariamente.
  3. Impedir que o consumidor altere esses dados.
  4. Minimize o código, ou seja, Não crie vários métodos e interfaces para cada conjunto de dados necessários, em geral, em apenas alguns lugares.

Não há condições agravantes, como multithreading, segurança no sentido de exceções, etc. Considere o caso mais simples. Aqui está como eu faria isso usando a linguagem mais familiar:

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; } } 


Obviamente, tudo está bem aqui, os dados não são alterados.

Conclusão
 *** After good consumer *** INT: 42 STRING: Fish LIST: [0, 1, 2, 3] 

E se alguém tentar mudar alguma coisa?


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


Sim, o código simplesmente não é compilado.

Erro
 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); 


O que poderia dar errado?


Este é o C ++ - uma linguagem com um rico arsenal de armas para atirar nas próprias pernas! Por exemplo:

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


Bem, na verdade tudo:
 *** After evil consumer *** INT: 7 STRING: James Bond LIST: [0, 1, 2, 3] 


Também observo que o uso de reinterpret_cast vez de const_cast nesse caso levará a um erro de compilação. Mas o elenco no estilo de C permitirá que você amplie esse foco.

Sim, esse código pode levar ao comportamento indefinido [C ++ 17 10.1.7.1/4] . Ele geralmente parece suspeito, o que é bom. É mais fácil capturar durante uma revisão.

É ruim que o código malicioso possa se esconder em qualquer parte do consumidor, mas funcionará de qualquer maneira:

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); } 


Conclusão
 *** After evil but cautious consumer *** INT: 42 STRING: Loki LIST: [0, 1, 2, 3] 


Vantagens e desvantagens do C ++ neste contexto


O que é bom:
  • você pode facilmente declarar acesso de leitura a qualquer coisa
  • A violação acidental dessa restrição é detectada no estágio de compilação, porque objetos constantes e não constantes podem ter interfaces diferentes
  • Violação consciente pode ser detectada em uma revisão de código

O que é ruim:
  • é possível contornar deliberadamente a proibição de mudança
  • e executado em uma linha, ou seja, fácil pular na revisão de código
  • e pode levar a um comportamento indefinido
  • a definição de classe pode ser inflada devido à necessidade de implementar interfaces diferentes para objetos constantes e não constantes


Java


Em Java, pelo que entendi, é usada uma abordagem ligeiramente diferente. Tipos primitivos declarados como final são constantes no mesmo sentido que em C ++. Strings em Java são basicamente imutáveis, portanto, final String é o que precisamos neste caso.

As coleções podem ser colocadas em invólucros imutáveis, para os quais existem métodos estáticos da classe java.util.Collections - unmodifiableList , unmodifiableMap , etc. I.e. A interface para objetos constantes e não constantes é a mesma, mas objetos não constantes lançam uma exceção ao tentar alterá-los.

Quanto aos tipos personalizados, o próprio usuário precisará criar invólucros imutáveis. Em geral, aqui está minha opção para 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(); } } } 


Conclusão
 *** After good consumer *** INT: 42 STRING: Fish LIST: [0, 1, 2, 3] 


Falha na tentativa de alteração


Se você apenas tentar mudar alguma coisa, por exemplo:

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


Esse código será compilado, mas uma exceção será lançada em tempo de execução:

Exceção
 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) 


Tentativa bem sucedida


E se de um jeito ruim? Não há como remover o qualificador final do tipo. Mas em Java há uma coisa muito mais poderosa - a reflexão.

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"); } 


E imunidade sobre
 *** After evil consumer *** INT: 7 STRING: James Bond LIST: [0, 1, 2, 3] 


Esse código parece ainda mais suspeito do que o cosnt_cast em C ++; é ainda mais fácil cosnt_cast uma revisão. E também pode levar a efeitos imprevisíveis (ou seja, o Java tem UB ?). E também pode se esconder arbitrariamente profundamente.

Esses efeitos imprevisíveis podem dever-se ao fato de que quando o objeto final é alterado usando reflexão, o valor retornado pelo método hashCode() pode permanecer o mesmo. Objetos diferentes com o mesmo hash não são um problema, mas objetos idênticos com hashes diferentes são ruins.

Qual é o perigo de um hack desse tipo em Java especificamente para cadeias ( exemplo ): cadeias aqui podem ser armazenadas no pool e não relacionadas entre si, apenas as mesmas cadeias podem indicar o mesmo valor no pool. Mudou um - mudou todos eles.

Mas! A JVM pode ser executada com várias configurações de segurança. O Security Manager já padrão, sendo ativado, suprime todos os truques acima com reflexão:

Exceção
 $ 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) 


Vantagens e desvantagens do Java neste contexto


O que é bom:
  • existe uma palavra-chave final que de alguma forma limita a alteração de dados
  • existem métodos de biblioteca para transformar coleções em imutáveis
  • violação da imunidade consciente é facilmente detectada pela revisão do código
  • tem configurações de segurança da JVM

O que é ruim:
  • uma tentativa de alterar um objeto imutável aparecerá apenas em tempo de execução
  • para tornar um objeto de uma determinada classe imutável, você deverá escrever o invólucro apropriado
  • na ausência de configurações de segurança apropriadas, é possível alterar qualquer dado imutável
  • essa ação pode ter consequências imprevisíveis (embora talvez seja bom - quase ninguém fará isso)


Python


Bem, depois disso fui simplesmente varrida pelas ondas de curiosidade. Como essas tarefas são resolvidas, por exemplo, em Python? E eles estão decididos? De fato, em python não há constância em princípio, nem mesmo essas palavras-chave.

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() 


Conclusão
 *** 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] 


I.e. não são necessários truques, pegue-os e altere os campos de qualquer objeto.

Acordo de cavalheiro


A prática a seguir é aceita em python:
  • campos e métodos personalizados cujos nomes começam com um único sublinhado são campos e métodos protegidos ( protegidos em C ++ e Java)
  • campos e métodos personalizados com nomes começando com dois sublinhados são métodos e campos privados

O idioma até torna desconcertante para campos "particulares". Uma decoração muito ingênua, sem comparação com C ++, mas isso é suficiente para ignorar (mas não capturar) erros não intencionais (ou ingênuos).

Código
 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 


Conclusão
 *** After evil consumer *** INT: 42 


E para cometer um erro intencional, basta adicionar alguns caracteres.

Código
 def evil_consumer(foo): foo._Foo__int_value = 7 


Conclusão
 *** After evil consumer *** INT: 7 


Outra opção


Gostei da solução proposta por Oz N Tiram . Este é um decorador simples que, ao tentar alterar o campo somente leitura , lança uma exceção. Isso está um pouco além do escopo acordado (“não crie muitos métodos e interfaces”), mas, repito, gostei.

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' 


Conclusão
 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 


Mas isso não é uma panacéia. Mas pelo menos o código correspondente parece suspeito.

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


Conclusão
 *** After evil consumer *** INT: 7 STRING: James Bond LIST: [0, 1, 2, 3] 


As vantagens e desvantagens do Python neste contexto


Python parece ser muito ruim? Não, isso é apenas outra filosofia da linguagem. Geralmente, é expressa pela frase " Todos nós somos adultos concordantes aqui " ( Todos nós somos adultos concordantes aqui ). I.e. supõe-se que ninguém se desvie especificamente das normas aceitas. O conceito não é certo, mas tem direito à vida.

O que é bom:
  • é declarado abertamente que os programadores devem monitorar os direitos de acesso, não o compilador ou intérprete
  • existe uma convenção de nomenclatura geralmente aceita para campos e métodos seguros e privados
  • algumas violações de acesso são facilmente detectadas em uma revisão de código

O que é ruim:
  • no nível do idioma, é impossível restringir o acesso aos campos da classe
  • tudo depende apenas da boa vontade e honestidade dos desenvolvedores
  • erros ocorrem apenas em tempo de execução


Go


Outra linguagem que sinto periodicamente (principalmente apenas lendo artigos), embora ainda não tenha escrito uma linha de código comercial. A palavra-chave const está basicamente lá, mas apenas cadeias e valores inteiros conhecidos em tempo de compilação (ou seja, constexpr de C ++) podem ser constantes. Mas os campos da estrutura não podem. I.e. se os campos são declarados abertos, acontece como em python - altere quem você deseja. Desinteressante. Eu nem vou dar um código de exemplo.

Bem, deixe os campos serem privados e seus valores sejam obtidos através de chamadas para métodos abertos. Posso obter lenha no Go? Claro, também há reflexão aqui.

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()) } 


Conclusão
 *** 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] 


A propósito, as strings no Go são imutáveis, como no Java. Fatias e mapas são mutáveis ​​e, diferentemente do Java, não há como o núcleo da linguagem torná-los imutáveis. Somente geração de código (corrija se estiver errado). I.e. mesmo que tudo seja feito corretamente, não use truques sujos, basta retornar a fatia do método - essa fatia sempre pode ser alterada.

A comunidade Gopher claramente carece de tipos imutáveis, mas certamente não haverá nenhum no Go 1.x.

Vantagens e desvantagens do Go neste contexto


Na minha visão inexperiente sobre as possibilidades de proibir a alteração dos campos das estruturas Go, ela está em algum lugar entre Java e Python, mais próximo desta última. Ao mesmo tempo, o Go (não conheci, embora estivesse procurando) o princípio Python dos adultos. Mas existe: dentro de um pacote, tudo tem acesso a tudo, restam apenas rudimentos das constantes, a presença da ausência de coleções imutáveis. I.e. se o desenvolvedor puder ler alguns dados, com alta probabilidade, ele poderá escrever algo lá. O que, como em python, transmite a maior parte da responsabilidade do compilador para a pessoa.

O que é bom:
  • todos os erros de acesso ocorrem durante a compilação
  • truques sujos baseados em reflexão são claramente visíveis na revisão

O que é ruim:
  • simplesmente não existe o conceito de um "conjunto de dados somente leitura"
  • é impossível restringir o acesso aos campos de estrutura dentro de um pacote
  • para proteger os campos de alterações fora do pacote, você precisará escrever getters
  • todas as coleções de referência são mutáveis
  • com a ajuda da reflexão, você pode até alterar campos particulares


Erlang


Isso está fora de competição. Ainda assim, Erlang é uma linguagem com um paradigma muito diferente dos quatro acima. Depois que o estudei com grande interesse, gostei muito de me fazer pensar em um estilo funcional. Infelizmente, porém, não encontrei uma aplicação prática dessas habilidades.

Portanto, nesse idioma, o valor de uma variável pode ser atribuído apenas uma vez. E quando a função é chamada, todos os argumentos são passados ​​por valor, ou seja, uma cópia deles é feita (mas há uma otimização da recursão da cauda).

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"). 


Conclusão
 *** After good consumer *** INT: 42 STRING: Fish LIST: [0,1,2,3] *** After evil consumer *** INT: 42 STRING: Fish LIST: [0,1,2,3] 


Obviamente, você pode fazer cópias para todos os espirros e, assim, se proteger da corrupção de dados em outros idiomas. Mas há uma linguagem (e certamente não uma) em que simplesmente não pode ser feita de outra maneira!

Vantagens e desvantagens de Erlang neste contexto


O que é bom:
  • os dados não podem ser alterados

O que é ruim:
  • copiando, copiando em qualquer lugar


Em vez de conclusões e conclusões


E qual é o resultado? Bem, além do fato de que soprei poeira de alguns livros que li há muito tempo, estiquei os dedos, escrevi um programa inútil em 5 idiomas diferentes e arranhei o FAQ?

Em primeiro lugar, parei de pensar que C ++ é a linguagem mais confiável em termos de proteção contra um tolo ativo. Apesar de toda a sua flexibilidade e rica sintaxe. Agora, estou inclinado a pensar que o Java, nesse sentido, fornece mais proteção. Esta não é uma conclusão muito original, mas para mim acho muito útil.

Em segundo lugar, de repente formulei para mim mesma a idéia de que as linguagens de programação podem ser divididas entre aquelas que tentam restringir o acesso a determinados dados no nível de sintaxe e semântica, e aquelas que nem sequer tentam mudar essas preocupações para os usuários . Assim, o limite de entrada, as melhores práticas, os requisitos para os participantes do desenvolvimento da equipe (técnicos e pessoais) devem diferir de alguma forma, dependendo do idioma de interesse selecionado. Eu adoraria ler sobre esse assunto.

Terceiro: não importa como a linguagem tente proteger os dados da gravação, o usuário quase sempre poderá fazer isso se desejar ("quase" por causa de Erlang). E se você se limitar aos principais idiomas, é sempre fácil. E acontece que todas essas const e final nada mais são do que recomendações, instruções para o uso correto de interfaces. Nem todas as línguas têm, mas ainda prefiro ter essas ferramentas no meu arsenal.

E quarto, a coisa mais importante: como nenhuma linguagem (convencional) pode impedir um desenvolvedor de fazer coisas desagradáveis, a única coisa que mantém esse desenvolvedor ativo é sua própria decência. E acontece que, quando eu introduzo constmeu código, não proíbo algo para meus colegas (e para o meu futuro eu), mas deixo as instruções, acreditando que eles (e eu) os seguiremos. I.e.Eu confio nos meus colegas.

Não, eu sei há muito tempo que o desenvolvimento de software moderno é em 99,99% dos casos, o trabalho em equipe. Mas tive sorte, todos os meus colegas eram pessoas "adultas, responsáveis". Para mim, sempre foi assim, e é dado como certo que todos os membros da equipe cumprem as regras estabelecidas. Meu caminho para perceber que constantemente confiamos e respeitamos um ao outro tem sido longo, mas malditamente calmo e seguro.

PS


Se alguém estiver interessado nos exemplos de código usados, você pode levá-los aqui .

Source: https://habr.com/ru/post/pt447478/


All Articles