Generalmente soy un programador de C ++. Pues sucedió. La gran mayoría del código comercial que he escrito en mi carrera es C ++. Realmente no me gusta un sesgo tan fuerte de mi experiencia personal hacia un idioma, y trato de no perder la oportunidad de escribir algo en otro idioma. Y mi empleador actual repentinamente brindó esa oportunidad: me comprometí a hacer que una no sea la utilidad más trivial en Java. La elección del lenguaje de implementación se realizó por razones históricas, y no me importó. Java, entonces Java, cuanto menos familiar para mí, mejor.
Entre otras cosas, tenía una tarea bastante simple: formar una cierta serie de datos conectados lógicamente y transferirlos a un determinado consumidor. Puede haber varios consumidores, y de acuerdo con el principio de encapsulación, el código de transmisión (productor) no tiene idea de qué hay dentro y qué puede hacer con los datos de origen. Pero el fabricante necesita que cada consumidor reciba los mismos datos. No quería hacer copias y darlas. Esto significa que de alguna manera debemos privar a los consumidores de la oportunidad de cambiar los datos que se les transmiten.
Fue entonces cuando mi inexperiencia en Java se hizo sentir. Me faltaban las características del lenguaje en comparación con C ++. Sí, aquí está la palabra clave
final
, pero el
final Object
es como
Object* const
en C ++, no
const Object*
. Es decir en la
final List<String>
puede agregar filas, por ejemplo. Es un negocio de C ++: poner
const
todas partes de acuerdo con el testamento de Myers, ¡y eso es todo! Nadie cambiará nada. Entonces? Bueno, en realidad no. Pensé un poco
en esto en
lugar de hacer esa utilidad en mi tiempo libre, y eso es a lo que llegué.
C ++
Déjame recordarte la tarea misma:
- Crea un conjunto de datos una vez.
- No copie nada innecesariamente.
- Evitar que el consumidor cambie estos datos.
- Minimizar código, es decir No cree un montón de métodos e interfaces para cada conjunto de datos que se necesita, en general, en solo un par de lugares.
Sin condiciones agravantes, como subprocesos múltiples, seguridad en el sentido de excepciones, etc. Considere el caso más simple. Así es como lo haría usando el lenguaje más 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, todo está bien aquí, los datos no han cambiado.
¿Y si alguien intenta cambiar algo?
main.cpp void stupidConsumer(const Foo& foo) { foo.listValue.push_back(100); }
Sí, el código simplemente no se compila.
Error 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é podría salir mal?
¡Esto es C ++, un lenguaje con un rico arsenal de armas para disparar con tus propias piernas! Por ejemplo:
main.cpp void evilConsumer(const Foo& foo) { const_cast<int&>(foo.intValue) = 7; const_cast<std::string&>(foo.strValue) = "James Bond"; }
También noto que usar
reinterpret_cast
lugar de
const_cast
en este caso conducirá a un error de compilación. Pero el elenco en el estilo de C te permitirá aumentar este enfoque.
Sí, dicho código puede conducir a un comportamiento indefinido
[C ++ 17 10.1.7.1/4] . Generalmente parece sospechoso, lo cual es bueno. Es más fácil de atrapar durante una revisión.
Es malo que el código malicioso pueda esconderse en cualquier lugar del consumidor, pero funcionará de todos modos:
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); }
Ventajas y desventajas de C ++ en este contexto
Lo cual es bueno:
- puedes declarar fácilmente el acceso de lectura a cualquier cosa
- La violación accidental de esta restricción se detecta en la etapa de compilación, porque Los objetos constantes y no constantes pueden tener interfaces diferentes
- La violación consciente se puede detectar en una revisión de código
Lo que es malo:
- es posible eludir deliberadamente la prohibición del cambio
- y ejecutado en una línea, es decir fácil de omitir en la revisión de código
- y puede conducir a un comportamiento indefinido
- La definición de clase se puede inflar debido a la necesidad de implementar diferentes interfaces para objetos constantes y no constantes
Java
En Java, según tengo entendido, se utiliza un enfoque ligeramente diferente. Los tipos primitivos declarados como
final
son constantes en el mismo sentido que en C ++. Las cadenas en Java son básicamente inmutables, por lo que la
final String
es lo que necesitamos en este caso.
Las colecciones se pueden colocar en envoltorios inmutables, para los cuales existen métodos estáticos de la clase
java.util.Collections
:
unmodifiableList
,
unmodifiableMap
, etc. Es decir La interfaz para objetos constantes y no constantes es la misma, pero los objetos no constantes generan una excepción al intentar cambiarlos.
En cuanto a los tipos personalizados, el propio usuario tendrá que crear envoltorios inmutables. En general, aquí está mi opción 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) {
Intento de cambio fallido
Si solo intenta cambiar algo, por ejemplo:
Main.java private static void stupidConsumer(final Foo foo) { foo.listValue.add(100); }
Este código se compilará, pero se lanzará una excepción en tiempo de ejecución:
Excepción 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)
Intento exitoso
¿Y si en el mal sentido? No hay forma de eliminar el calificador
final
del tipo. Pero en Java hay una cosa mucho más poderosa: la reflexión.
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"); }
Tal código parece aún más sospechoso que
cosnt_cast
en C ++, es aún más fácil de atrapar en una revisión. Y también puede conducir a
efectos impredecibles (es decir, ¿Java tiene
UB ?). Y también puede esconderse arbitrariamente profundamente.
Estos efectos impredecibles pueden deberse al hecho de que cuando se cambia el objeto
final
utilizando la reflexión, el valor devuelto por el método
hashCode()
puede permanecer igual. Diferentes objetos con el mismo hash no es un problema, pero objetos idénticos con diferentes hash son malos.
¿Cuál es el peligro de tal pirateo en Java específicamente para cadenas (
ejemplo ): las cadenas aquí se pueden almacenar en el grupo y, sin estar relacionadas entre sí, solo las mismas cadenas pueden indicar el mismo valor en el grupo. Cambió uno, los cambió a todos.
Pero!
JVM se puede ejecutar con varias configuraciones de seguridad.
Security Manager
predeterminado, que se activa, suprime todos los trucos anteriores con reflexión:
Excepción $ 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)
Ventajas y desventajas de Java en este contexto.
Lo cual es bueno:
- hay una palabra clave
final
que de alguna manera limita el cambio de datos - existen métodos de biblioteca para convertir colecciones en inmutables
- La violación consciente de la inmunidad se detecta fácilmente mediante la revisión del código
- tener configuraciones de seguridad JVM
Lo que es malo:
- un intento de cambiar un objeto inmutable aparecerá solo en tiempo de ejecución
- Para que un objeto de una determinada clase sea inmutable, deberá escribir el contenedor apropiado usted mismo
- en ausencia de configuraciones de seguridad apropiadas, es posible cambiar cualquier información inmutable
- esta acción puede tener consecuencias impredecibles (aunque tal vez sea buena, casi nadie lo hará)
Pitón
Bueno, después de eso simplemente fui arrastrado por las olas de curiosidad. ¿Cómo se resuelven tales tareas, por ejemplo, en Python? ¿Y están decididos en absoluto? De hecho, en Python no hay constancia en principio, incluso no hay tales palabras clave.
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()
Es decir no se necesitan trucos, tómalo y cambia los campos de cualquier objeto.
Acuerdo de caballeros
La siguiente
práctica es aceptada en python:
- Los campos y métodos personalizados cuyos nombres comienzan con un solo guión bajo son campos y métodos protegidos ( protegidos en C ++ y Java)
- Los campos y métodos personalizados con nombres que comienzan con dos guiones bajos son campos y métodos privados
El lenguaje incluso hace que los campos sean "privados". Una decoración muy ingenua, sin comparación con C ++, pero esto es suficiente para ignorar (pero no detectar) errores no intencionales (o ingenuos).
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
Y para cometer un error intencionalmente, solo agregue algunos caracteres.
Código def evil_consumer(foo): foo._Foo__int_value = 7
Otra opcion
Me gustó la solución propuesta por
Oz N Tiram . Este es un decorador simple que al intentar cambiar el campo de
solo lectura produce una excepción. Esto está un poco más allá del alcance acordado ("no cree un montón de métodos e interfaces"), pero, repito, me gustó.
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'
Conclusión 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
Pero esto no es una panacea. Pero al menos el código correspondiente parece sospechoso.
main.py def evil_consumer(foo): foo.__dict__['int_value'] = 7 foo.__dict__['str_value'] = 'James Bond'
Las ventajas y desventajas de Python en este contexto.
¿Python parece ser muy malo? No, esta es solo otra filosofía del lenguaje. Por lo general, se expresa con la frase "
Aquí todos somos adultos con consentimiento " (
Todos aquí somos adultos con consentimiento ). Es decir se supone que nadie se desviará específicamente de las normas aceptadas. El concepto no es seguro, pero tiene derecho a la vida.
Lo cual es bueno:
- se declara abiertamente que los programadores deben monitorear los derechos de acceso, no el compilador o el intérprete
- existe una convención de nomenclatura generalmente aceptada para campos y métodos seguros y privados
- Algunas violaciones de acceso se detectan fácilmente en una revisión de código
Lo que es malo:
- a nivel de idioma es imposible restringir el acceso a los campos de la clase
- todo se basa únicamente en la buena voluntad y honestidad de los desarrolladores
- los errores ocurren solo en tiempo de ejecución
Ir
Otro lenguaje que siento periódicamente (principalmente solo leyendo artículos), aunque todavía no he escrito una línea de código comercial. La palabra clave
const
está básicamente allí, pero solo las cadenas y los valores enteros conocidos en tiempo de compilación (es decir,
constexpr
de C ++) pueden ser constantes. Pero los campos de estructura no pueden. Es decir si los campos se declaran abiertos, entonces resulta como en python: cambia a quién quieras. Poco interesante Ni siquiera daré un código de ejemplo.
Bueno, deje que los campos sean privados y obtenga sus valores mediante llamadas a métodos abiertos. ¿Puedo obtener leña en Go? Por supuesto, también hay una reflexión aquí.
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) {
Por cierto, las cadenas en Go son inmutables, como en Java. Los sectores y los mapas son mutables y, a diferencia de Java, en el núcleo del lenguaje no hay forma de hacerlos inmutables. Solo generación de código (correcto si me equivoco). Es decir incluso si todo se hace correctamente, no use trucos sucios, solo devuelva el segmento del método; este segmento siempre se puede cambiar.
La comunidad Gopher claramente
carece de tipos inmutables, pero ciertamente no habrá ninguno en Go 1.x.
Ventajas y desventajas de Go en este contexto
Desde mi punto de vista inexperto sobre las posibilidades de prohibir cambiar los campos de las estructuras de Go, está en algún lugar entre Java y Python, más cerca de este último. Al mismo tiempo, Go no cumple (no he conocido, aunque estaba buscando) el principio de Python para adultos. Pero hay: dentro de un paquete todo tiene acceso a todo, solo queda el rudimento de las constantes, la presencia de la ausencia de colecciones inmutables. Es decir Si el desarrollador puede leer algunos datos, entonces con una alta probabilidad puede escribir algo allí. Lo cual, como en Python, transmite la mayor parte de la responsabilidad del compilador a la persona.
Lo cual es bueno:
- todos los errores de acceso ocurren durante la compilación
- los trucos sucios basados en la reflexión son claramente visibles en la revisión
Lo que es malo:
- el concepto de "conjunto de datos de solo lectura" simplemente no es
- Es imposible restringir el acceso a los campos de estructura dentro de un paquete
- Para proteger los campos de cambios fuera del paquete, deberá escribir getters
- todas las colecciones de referencia son mutables
- con la ayuda de la reflexión, incluso puedes cambiar los campos privados
Erlang
Esto está fuera de competencia. Aún así, Erlang es un lenguaje con un paradigma muy diferente de los cuatro anteriores. Una vez que lo estudié con gran interés, realmente me gustó hacerme pensar en un estilo funcional. Pero, desafortunadamente, no encontré una aplicación práctica de estas habilidades.
Entonces, en este lenguaje, el valor de una variable se puede asignar solo una vez. Y cuando se llama a la función, todos los argumentos se pasan por valor, es decir se hace una copia de ellos (pero hay una optimización de la recursividad de la cola).
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").
Por supuesto, puede hacer copias para cada estornudo y así protegerse de la corrupción de datos en otros idiomas. ¡Pero hay un lenguaje (y ciertamente ninguno) en el que simplemente no se puede hacer de otra manera!
Ventajas y desventajas de Erlang en este contexto.
Lo cual es bueno:
- los datos no pueden modificarse en absoluto
Lo que es malo:
- copiando, copiando en todas partes
En lugar de conclusiones y conclusiones
¿Y cuál es el resultado? Bueno, además del hecho de que expulsé el polvo de un par de libros que leí hace mucho tiempo, estiré los dedos, escribí un programa inútil en 5 idiomas diferentes y rasqué las preguntas frecuentes.
En primer lugar, dejé de pensar que C ++ es el lenguaje más confiable en términos de protección contra un tonto activo. A pesar de toda su flexibilidad y rica sintaxis. Ahora me inclino a pensar que Java a este respecto proporciona más protección. Esta no es una conclusión muy original, pero para mí me parece muy útil.
En segundo lugar, de repente formulé para mí la idea de que los lenguajes de programación se pueden dividir aproximadamente en aquellos que intentan restringir el acceso a ciertos datos a nivel de sintaxis y semántica, y aquellos que ni siquiera intentan trasladar estas preocupaciones a los usuarios . En consecuencia, el umbral de entrada, las mejores prácticas, los requisitos para los participantes en el desarrollo del equipo (tanto técnicos como personales) deberían diferir de alguna manera según el idioma de interés seleccionado. Me encantaría leer sobre este tema.
En tercer lugar: no importa cómo el idioma intente proteger los datos de la escritura, el usuario casi siempre puede hacer esto si lo desea ("casi" debido a Erlang). Y si se limita a los idiomas principales, siempre es fácil. Y resulta que todas estas
const
y
final
no son más que recomendaciones, instrucciones para el uso correcto de las interfaces. No todos los idiomas lo tienen, pero todavía prefiero tener esas herramientas en mi arsenal.
Y cuarto, lo más importante: dado que ningún lenguaje (convencional) puede evitar que un desarrollador haga cosas desagradables, lo único que mantiene a este desarrollador activo es su propia decencia. Y resulta que, cuando pongo const
mi código, no les prohíbo algo a mis colegas (y a mi futuro yo), sino que dejo las instrucciones, creyendo que ellos (y yo) los seguiremos. Es decir
Yo confío en mis colegas.No, hace mucho que sé que el desarrollo de software moderno se encuentra en el 99.99% de los casos de trabajo en equipo. Pero tuve suerte, todos mis colegas eran personas "adultas, responsables". Para mí, siempre ha sido así, y se da por sentado que todos los miembros del equipo cumplen con las reglas establecidas. Mi camino para darme cuenta de que confiamos y nos respetamos constantemente ha sido largo, pero muy tranquilo y seguro.PS
Si alguien está interesado en los ejemplos de código utilizados, puede llevarlos aquí .