En général, je suis un programmeur C ++. Et bien c'est arrivé. La grande majorité du code commercial que j'ai écrit au cours de ma carrière est C ++. Je n'aime pas vraiment un parti pris aussi fort de mon expérience personnelle vers une langue, et j'essaie de ne pas manquer l'occasion d'écrire quelque chose dans une autre langue. Et mon employeur actuel a soudainement fourni une telle opportunité: je me suis engagé à en faire un qui n'est pas l'utilitaire le plus trivial de Java. Le choix du langage d'implémentation a été fait pour des raisons historiques, et cela ne me dérange pas. Java donc Java, le moins familier pour moi - mieux c'est.
Entre autres choses, j'avais une tâche assez simple: une fois pour former un certain ensemble de données logiquement connectées et les transférer à un certain consommateur. Il peut y avoir plusieurs consommateurs, et selon le principe d'encapsulation, le code émetteur (producteur) n'a aucune idée de ce qu'il y a à l'intérieur et de ce qu'il peut faire avec les données source. Mais le fabricant a besoin que chaque consommateur reçoive les mêmes données. Je ne voulais pas faire de copies ni les remettre. Cela signifie que nous devons en quelque sorte priver les consommateurs de la possibilité de modifier les données qui leur sont transmises.
C'est alors que mon inexpérience à Java s'est fait sentir. Il me manquait les fonctionnalités du langage par rapport à C ++. Oui, il y a le mot-clé
final
, mais l'
final Object
est comme
Object* const
en C ++, pas
const Object*
. C'est-à-dire dans la
final List<String>
vous pouvez ajouter des lignes, par exemple. C'est du C ++: mettre
const
partout selon le testament de Myers, et c'est tout! Personne ne changera rien. Alors? Enfin, pas vraiment. J'y ai réfléchi un peu
au lieu de faire cet utilitaire à mon gré, et c'est ce que je suis venu.
C ++
Permettez-moi de vous rappeler la tâche elle-même:
- Créez une fois un ensemble de données.
- Ne copiez rien inutilement.
- Empêcher le consommateur de modifier ces données.
- Réduisez le code, c.-à-d. Ne créez pas un tas de méthodes et d'interfaces pour chaque ensemble de données qui est nécessaire, en général, à quelques endroits seulement.
Pas de conditions aggravantes telles que le multithreading, la sécurité au sens d'exceptions, etc. Prenons le cas le plus simple. Voici comment je le ferais en utilisant le langage le plus familier:
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; } }
De toute évidence, tout va bien ici, les données sont inchangées.
Et si quelqu'un essaie de changer quelque chose?
main.cpp void stupidConsumer(const Foo& foo) { foo.listValue.push_back(100); }
Oui, le code ne compile tout simplement pas.
Erreur 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);
Qu'est-ce qui pourrait mal tourner?
C'est C ++ - un langage avec un riche arsenal d'armes pour tirer sur vos propres jambes! Par exemple:
main.cpp void evilConsumer(const Foo& foo) { const_cast<int&>(foo.intValue) = 7; const_cast<std::string&>(foo.strValue) = "James Bond"; }
Je note également que l'utilisation de
const_cast
au lieu de
const_cast
dans ce cas entraînera une erreur de compilation. Mais la distribution dans le style de C vous permettra de tourner ce focus.
Oui, un tel code peut conduire à un comportement
indéfini [C ++ 17 10.1.7.1/4] . Il a généralement l'air suspect, ce qui est bien. Il est plus facile à attraper lors d'un examen.
Il est mauvais que le code malveillant puisse se cacher n'importe où au fond du consommateur, mais cela fonctionnera quand même:
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); }
Avantages et inconvénients du C ++ dans ce contexte
Ce qui est bien:
- vous pouvez facilement déclarer l'accès en lecture à tout
- une violation accidentelle de cette restriction est détectée au stade de la compilation, car les objets constants et non constants peuvent avoir différentes interfaces
- Une violation consciente peut être détectée lors d'une révision de code
Ce qui est mauvais:
- un contournement délibéré de l'interdiction du changement est possible
- et exécuté en une seule ligne, c'est-à-dire examen facile du code
- et peut conduire à un comportement indéfini
- la définition de classe peut être gonflée en raison de la nécessité d'implémenter différentes interfaces pour les objets constants et non constants
Java
En Java, si je comprends bien, une approche légèrement différente est utilisée. Les types primitifs déclarés comme
final
sont constants dans le même sens qu'en C ++. Les chaînes en Java sont fondamentalement immuables, donc la
final String
est ce dont nous avons besoin dans ce cas.
Les collections peuvent être placées dans des wrappers immuables, pour lesquels il existe des méthodes statiques de la classe
java.util.Collections
-
unmodifiableList
,
unmodifiableMap
, etc. C'est-à-dire L'interface pour les objets constants et non constants est la même, mais les objets non constants lèvent une exception lorsqu'ils tentent de les modifier.
Quant aux types personnalisés, l'utilisateur lui-même devra créer des wrappers immuables. En général, voici mon option pour 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) {
Échec de la tentative de modification
Si vous essayez simplement de changer quelque chose, par exemple:
Main.java private static void stupidConsumer(final Foo foo) { foo.listValue.add(100); }
Ce code sera compilé, mais une exception sera levée lors de l'exécution:
Exception 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)
Tentative réussie
Et si dans le mauvais sens? Il n'y a aucun moyen de supprimer le qualificatif
final
du type. Mais à Java, il y a une chose beaucoup plus puissante - la réflexion.
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"); }
Un tel code semble encore plus suspect que
cosnt_cast
en C ++, il est encore plus facile de le
cosnt_cast
. Et cela peut également entraîner
des effets imprévisibles (c'est-à-dire, Java a
- t-il
UB ?). Et il peut aussi se cacher profondément et arbitrairement.
Ces effets imprévisibles peuvent être dus au fait que lorsque l'objet
final
est modifié à l'aide de la réflexion, la valeur renvoyée par la
hashCode()
peut rester la même. Différents objets avec le même hachage ne sont pas un problème, mais des objets identiques avec des hachages différents sont mauvais.
Quel est le plus dangereux un tel hack en Java spécifiquement pour les chaînes (
exemple ): les chaînes ici peuvent être stockées dans le pool, et sans relation les unes avec les autres, seules les mêmes chaînes peuvent indiquer la même valeur dans le pool. Changé un - tout changé.
Mais!
La JVM peut être exécutée avec différents paramètres de sécurité. Le
Security Manager
déjà par défaut, en cours d'activation, supprime toutes les astuces ci-dessus avec réflexion:
Exception $ 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)
Avantages et inconvénients de Java dans ce contexte
Ce qui est bien:
- il y a un
final
mot clé qui limite en quelque sorte le changement de données - il existe des méthodes de bibliothèque pour transformer les collections en immuables
- la violation de l'immunité consciente est facilement détectée par l'examen du code
- avoir des paramètres de sécurité JVM
Ce qui est mauvais:
- une tentative de modification d'un objet immuable n'apparaîtra qu'au moment de l'exécution
- pour rendre immuable un objet d'une certaine classe, vous devrez écrire vous-même le wrapper approprié
- en l'absence de paramètres de sécurité appropriés, il est possible de modifier toutes les données immuables
- cette action peut avoir des conséquences imprévisibles (même si c'est peut-être bien - presque personne ne le fera)
Python
Eh bien, après cela, j'ai simplement été balayé par les vagues de curiosité. Comment ces tâches sont-elles résolues, par exemple, en Python? Et sont-ils décidés du tout? En effet, en python il n'y a pas de constance en principe, même il n'y a pas de tels mots-clés.
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()
C'est-à-dire aucune astuce n'est nécessaire, prenez-la et changez les champs de n'importe quel objet.
Gentleman's agreement
La
pratique suivante
est acceptée en python:
- les champs et méthodes personnalisés dont les noms commencent par un seul trait de soulignement sont protégés ( protégés en C ++ et Java) champs et méthodes
- les champs et méthodes personnalisés dont les noms commencent par deux traits de soulignement sont des champs et des méthodes privés
La langue fait même
mal aux champs "privés". Une décoration très naïve, pas de comparaison avec C ++, mais cela suffit pour ignorer (mais pas attraper) les erreurs involontaires (ou naïves).
Code 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
Et pour faire une erreur intentionnellement, ajoutez simplement quelques caractères.
Code def evil_consumer(foo): foo._Foo__int_value = 7
Une autre option
J'ai aimé la solution proposée par
Oz N Tiram . Il s'agit d'un simple décorateur qui, lors d'une tentative de modification du champ en
lecture seule , génère une exception. C'est un peu au-delà de la portée convenue («ne créez pas un tas de méthodes et d'interfaces»), mais, je le répète, j'ai bien aimé.
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'
Conclusion 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
Mais ce n'est pas une panacée. Mais au moins, le code correspondant semble suspect.
main.py def evil_consumer(foo): foo.__dict__['int_value'] = 7 foo.__dict__['str_value'] = 'James Bond'
Les avantages et les inconvénients de Python dans ce contexte
Le python semble-t-il être très mauvais? Non, c'est juste une autre philosophie du langage. Habituellement, il est exprimé par la phrase «
Nous sommes tous des adultes consentants ici » (
Nous sommes tous des adultes consentants ici ). C'est-à-dire on suppose que personne ne s'écartera spécifiquement des normes acceptées. Le concept n'est pas certain, mais il a droit à la vie.
Ce qui est bien:
- il est ouvertement déclaré que les programmeurs doivent surveiller les droits d'accès, pas le compilateur ou l'interpréteur
- il existe une convention de dénomination généralement acceptée pour les champs et méthodes sécurisés et privés
- certaines violations d'accès sont facilement détectées lors d'une révision de code
Ce qui est mauvais:
- au niveau de la langue il est impossible de restreindre l'accès aux champs de la classe
- tout repose uniquement sur la bonne volonté et l'honnêteté des développeurs
- les erreurs se produisent uniquement au moment de l'exécution
Allez
Une autre langue que je ressens périodiquement (la plupart du temps je ne lis que des articles), même si je n'ai pas encore écrit de ligne de code commercial dessus. Le mot-clé
const
est fondamentalement là, mais seules les chaînes et les valeurs entières connues au moment de la compilation (c'est-à-dire
constexpr
de C ++) peuvent être des constantes. Mais les champs de structure ne le peuvent pas. C'est-à-dire si les champs sont déclarés ouverts, cela se passe comme en python - changez qui vous voulez. Inintéressant. Je ne donnerai même pas d'exemple de code.
Eh bien, que les champs soient privés et que leurs valeurs soient obtenues par des appels à des méthodes ouvertes. Puis-je obtenir du bois de chauffage à Go? Bien sûr, il y a aussi une réflexion ici.
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) {
Soit dit en passant, les chaînes dans Go sont immuables, comme dans Java. Les tranches et les cartes sont mutables, et contrairement à Java, il n'y a aucun moyen au cœur du langage de les rendre immuables. Génération de code uniquement (correct si je me trompe). C'est-à-dire même si tout est fait correctement, n'utilisez pas de trucs sales, renvoyez simplement la tranche de la méthode - cette tranche peut toujours être modifiée.
La communauté des gopher
manque clairement
de types immuables, mais il n'y en aura certainement pas dans Go 1.x.
Avantages et inconvénients de Go dans ce contexte
Dans mon point de vue inexpérimenté sur les possibilités d'interdire de changer les champs des structures Go, c'est quelque part entre Java et Python, plus proche de ce dernier. En même temps, Go ne respecte pas (je n'ai pas rencontré, bien que je cherchais) le principe Python des adultes. Mais il y a: à l'intérieur d'un paquet tout a accès à tout, il ne reste que des rudiments des constantes, la présence de l'absence de collections immuables. C'est-à-dire si le développeur peut lire certaines données, alors avec une forte probabilité il peut y écrire quelque chose. Ce qui, comme en python, transfère la plupart des responsabilités du compilateur à la personne.
Ce qui est bien:
- toutes les erreurs d'accès se produisent lors de la compilation
- les trucs sales basés sur la réflexion sont clairement visibles dans la revue
Ce qui est mauvais:
- le concept de «jeu de données en lecture seule» n’est tout simplement pas
- il est impossible de restreindre l'accès aux champs de structure dans un package
- pour protéger les champs des modifications en dehors du paquet, vous devrez écrire des getters
- toutes les collections de référence sont mutables
- à l'aide de la réflexion, vous pouvez même changer les champs privés
Erlang
C'est hors compétition. Pourtant, Erlang est une langue avec un paradigme très différent des quatre ci-dessus. Une fois que je l'ai étudié avec grand intérêt, j'ai vraiment aimé me faire réfléchir dans un style fonctionnel. Mais, malheureusement, je n'ai pas trouvé d'application pratique de ces compétences.
Ainsi, dans ce langage, la valeur d'une variable ne peut être affectée qu'une seule fois. Et lorsque la fonction est appelée, tous les arguments sont passés par valeur, c'est-à-dire une copie est faite (mais il y a une optimisation de la récursivité de la queue).
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").
Bien sûr, vous pouvez faire des copies pour chaque éternuement et ainsi vous protéger de la corruption des données dans d'autres langues. Mais il y a un langage (et certainement pas un) où cela ne peut tout simplement pas être fait d'une autre manière!
Avantages et inconvénients d'Erlang dans ce contexte
Ce qui est bien:
- les données ne peuvent pas être modifiées du tout
Ce qui est mauvais:
Au lieu de conclusions et de conclusions
Et quel est le résultat? Eh bien, outre le fait que j'ai soufflé la poussière de quelques livres que j'ai lus il y a longtemps, j'ai tendu les doigts, écrit un programme inutile en 5 langues différentes et gratté la FAQ?
Tout d'abord, j'ai cessé de penser que C ++ est le langage le plus fiable en termes de protection contre un fou actif. Malgré toute sa flexibilité et sa riche syntaxe. Maintenant, je suis porté à penser que Java à cet égard offre plus de protection. Ce n'est pas une conclusion très originale, mais pour moi je la trouve très utile.
Deuxièmement, je me suis soudain formulé l'idée que les langages de programmation peuvent être grossièrement divisés en ceux qui tentent de restreindre l'accès à certaines données au niveau de la syntaxe et de la sémantique, et ceux qui n'essaient même pas de transférer ces préoccupations aux utilisateurs . En conséquence, le seuil d'entrée, les meilleures pratiques, les exigences pour les participants au développement d'équipe (à la fois techniques et personnels) devraient différer d'une manière ou d'une autre selon la langue d'intérêt sélectionnée. J'aimerais lire sur ce sujet.
Troisièmement: quelle que soit la façon dont la langue essaie de protéger les données contre l'écriture, l'utilisateur peut presque toujours le faire s'il le souhaite («presque» à cause d'Erlang). Et si vous vous limitez aux langues traditionnelles - c'est toujours facile. Et il s'avère que tous ces
const
et
final
ne sont rien de plus que des recommandations, des instructions pour l'utilisation correcte des interfaces. Toutes les langues ne l'ont pas, mais je préfère toujours avoir de tels outils dans mon arsenal.
Et quatrièmement, la chose la plus importante: comme aucun langage (traditionnel) ne peut empêcher un développeur de faire des choses désagréables, la seule chose qui maintient ce développeur est sa propre décence. Et il s'avère que, lorsque je mets const
mon code, je n'interdis pas quelque chose à mes collègues (et à mon futur moi), mais laisse des instructions, croyant qu'ils (et moi) les suivront. C'est-à-dire
J'ai confiance en mes collègues.Non, je sais depuis longtemps que le développement de logiciels modernes est dans 99,99% des cas en équipe. Mais j'ai eu de la chance, tous mes collègues étaient des gens «adultes, responsables». Pour moi, cela a toujours été le cas et il est tenu pour acquis que tous les membres de l'équipe respectent les règles établies. Mon chemin vers la prise de conscience que nous nous faisons constamment confiance et nous respectons a été long, mais sacrément calme et sûr.PS
Si quelqu'un s'intéresse aux exemples de code utilisés, vous pouvez les prendre ici .