¿Buena o mala práctica? Inicializando objetos en getter

167

Parece que tengo una extraña costumbre ... al menos según mi compañero de trabajo. Hemos estado trabajando juntos en un pequeño proyecto. La forma en que escribí las clases es (ejemplo simplificado):

[Serializable()]
public class Foo
{
    public Foo()
    { }

    private Bar _bar;

    public Bar Bar
    {
        get
        {
            if (_bar == null)
                _bar = new Bar();

            return _bar;
        }
        set { _bar = value; }
    }
}

Entonces, básicamente, solo inicializo cualquier campo cuando se llama a un captador y el campo sigue siendo nulo. Pensé que esto reduciría la sobrecarga al no inicializar ninguna propiedad que no se use en ningún lado.

ETA: La razón por la que hice esto es que mi clase tiene varias propiedades que devuelven una instancia de otra clase, que a su vez también tiene propiedades con aún más clases, y así sucesivamente. Llamar al constructor para la clase superior posteriormente llamaría a todos los constructores para todas estas clases, cuando no siempre son todos necesarios.

¿Hay alguna objeción contra esta práctica, aparte de la preferencia personal?

ACTUALIZACIÓN: He considerado las diferentes opiniones con respecto a esta pregunta y mantendré mi respuesta aceptada. Sin embargo, ahora he llegado a comprender mucho mejor el concepto y puedo decidir cuándo usarlo y cuándo no.

Contras:

  • Temas de seguridad de hilos
  • No obedecer una solicitud de "establecedor" cuando el valor pasado es nulo
  • Micro optimizaciones
  • El manejo de excepciones debe tener lugar en un constructor
  • Necesito verificar si hay un código nulo en la clase

Pros:

  • Micro optimizaciones
  • Las propiedades nunca devuelven nulo
  • Retrasar o evitar cargar objetos "pesados"

La mayoría de las desventajas no son aplicables a mi biblioteca actual, sin embargo, tendría que probar para ver si las "microoptimizaciones" realmente están optimizando algo.

ÚLTIMA ACTUALIZACIÓN:

Bien, cambié mi respuesta. Mi pregunta original era si este es un buen hábito o no. Y ahora estoy convencido de que no lo es. Tal vez aún lo usaré en algunas partes de mi código actual, pero no incondicionalmente y definitivamente no todo el tiempo. Así que voy a perder mi hábito y pensarlo antes de usarlo. ¡Gracias a todos!

John Willemse
fuente
14
Este es el patrón de carga perezosa, no es exactamente que le brinde un beneficio maravilloso aquí, pero sigue siendo algo bueno en mi humilde opinión.
Machinarius
28
La instanciación diferida tiene sentido si tiene un impacto medible en el rendimiento, o si esos miembros rara vez se usan y consumen una cantidad exorbitante de memoria, o si lleva mucho tiempo instanciarlos y solo quiere hacerlo a pedido. En cualquier caso, asegúrese de tener en cuenta los problemas de seguridad de subprocesos (su código actual no lo es ) y considere usar la clase Lazy <T> proporcionada .
Chris Sinclair
10
Creo que esta pregunta encaja mejor en codereview.stackexchange.com
Eduardo Brites
77
@PLB no es un patrón singleton.
Colin Mackay
30
Me sorprende que nadie haya mencionado un error grave con este código. Tienes una propiedad pública que puedo configurar desde afuera. Si lo configuro en NULL, siempre creará un nuevo objeto e ignorará mi acceso al setter. Esto podría ser un error muy grave. Para propiedades privadas, esto podría estar bien. Personalmente, no me gusta hacer optimizaciones tan prematuras. Agrega complejidad sin beneficio adicional.
SoluciónYogi

Respuestas:

170

Lo que tienes aquí es una implementación ingenua de "inicialización diferida".

Respuesta corta:

Usar la inicialización perezosa incondicionalmente no es una buena idea. Tiene su lugar, pero hay que tener en cuenta los impactos que tiene esta solución.

Antecedentes y explicación:

Implementación concreta:
primero veamos su muestra concreta y por qué considero que su implementación es ingenua:

  1. Viola el Principio de Menos Sorpresa (POLS) . Cuando se asigna un valor a una propiedad, se espera que este valor se devuelva. En su implementación, este no es el caso para null:

    foo.Bar = null;
    Assert.Null(foo.Bar); // This will fail
  2. Presenta algunos problemas de subprocesos: dos personas que llaman foo.Baren subprocesos diferentes pueden obtener dos instancias diferentes Bary una de ellas no tendrá conexión con la Fooinstancia. Cualquier cambio realizado en esa Barinstancia se pierde silenciosamente.
    Este es otro caso de violación de POLS. Cuando solo se accede al valor almacenado de una propiedad, se espera que sea seguro para subprocesos. Si bien podría argumentar que la clase simplemente no es segura para subprocesos, incluido el captador de su propiedad, tendría que documentar esto correctamente, ya que ese no es el caso normal. Además, la introducción de este problema es innecesaria, como veremos en breve.

En general:
ahora es el momento de analizar la inicialización diferida en general: la inicialización diferida
se usa generalmente para retrasar la construcción de objetos que tardan mucho tiempo en construirse o que requieren mucha memoria una vez que están completamente construidos.
Esa es una razón muy válida para usar la inicialización diferida.

Sin embargo, tales propiedades normalmente no tienen setters, lo que elimina el primer problema señalado anteriormente.
Además, se utilizaría una implementación segura para subprocesos, como Lazy<T>, para evitar el segundo problema.

Incluso cuando se consideran estos dos puntos en la implementación de una propiedad diferida, los siguientes puntos son problemas generales de este patrón:

  1. La construcción del objeto podría no tener éxito, lo que da como resultado una excepción de un captador de propiedades. Esta es otra violación de POLS y, por lo tanto, debe evitarse. Incluso la sección sobre propiedades en las "Pautas de diseño para desarrollar bibliotecas de clases" establece explícitamente que los captadores de propiedades no deben lanzar excepciones:

    Evite lanzar excepciones de captadores de propiedades.

    Los captadores de propiedades deben ser operaciones simples sin condiciones previas. Si un captador puede lanzar una excepción, considere rediseñar la propiedad como método.

  2. Las optimizaciones automáticas por parte del compilador se ven afectadas, es decir, la alineación y la predicción de ramificaciones. Consulte la respuesta de Bill K para obtener una explicación detallada.

La conclusión de estos puntos es la siguiente:
para cada propiedad individual que se implementa perezosamente, debería haber considerado estos puntos.
Eso significa que es una decisión por caso y no puede tomarse como una mejor práctica general.

Este patrón tiene su lugar, pero no es una mejor práctica general al implementar clases. No debe usarse incondicionalmente , por las razones indicadas anteriormente.


En esta sección, quiero analizar algunos de los puntos que otros han presentado como argumentos para usar la inicialización perezosa incondicionalmente:

  1. Serialización:
    EricJ declara en un comentario:

    Un objeto que puede ser serializado no tendrá su constructor invocado cuando esté deserializado (depende del serializador, pero muchos de los más comunes se comportan así). Poner el código de inicialización en el constructor significa que debe proporcionar soporte adicional para la deserialización. Este patrón evita esa codificación especial.

    Hay varios problemas con este argumento:

    1. La mayoría de los objetos nunca serán serializados. Agregar algún tipo de soporte cuando no es necesario viola YAGNI .
    2. Cuando una clase necesita soportar la serialización, existen formas de habilitarla sin una solución alternativa que no tenga nada que ver con la serialización a primera vista.
  2. Microoptimización: su argumento principal es que desea construir los objetos solo cuando alguien realmente accede a ellos. Entonces, en realidad estás hablando de optimizar el uso de la memoria.
    No estoy de acuerdo con este argumento por las siguientes razones:

    1. En la mayoría de los casos, unos pocos objetos más en la memoria no tienen ningún impacto en nada. Las computadoras modernas tienen bastante memoria. Sin un caso de problemas reales confirmados por un generador de perfiles, esta es una optimización prematura y hay buenas razones para ello.
    2. Reconozco el hecho de que a veces este tipo de optimización está justificada. Pero incluso en estos casos, la inicialización diferida no parece ser la solución correcta. Hay dos razones para hablar en contra:

      1. La inicialización diferida potencialmente perjudica el rendimiento. Tal vez solo marginalmente, pero como mostró la respuesta de Bill, el impacto es mayor de lo que uno podría pensar a primera vista. Entonces, este enfoque básicamente cambia el rendimiento versus la memoria.
      2. Si tiene un diseño en el que es un caso de uso común usar solo partes de la clase, esto sugiere un problema con el diseño en sí: la clase en cuestión probablemente tenga más de una responsabilidad. La solución sería dividir la clase en varias clases más enfocadas.
Daniel Hilgarth
fuente
44
@ JohnWillemse: Ese es un problema de su arquitectura. Debes refactorizar tus clases de manera que sean más pequeñas y más enfocadas. No cree una clase para 5 cosas / tareas diferentes. Crea 5 clases en su lugar.
Daniel Hilgarth
26
@JohnWillemse Quizás considere esto un caso de optimización prematura entonces. A menos que tenga un cuello de botella de rendimiento / memoria medido , lo recomendaría en su contra, ya que aumenta la complejidad e introduce problemas de subprocesos.
Chris Sinclair
2
+1, esta no es una buena opción de diseño para el 95% de las clases. La inicialización diferida tiene sus ventajas, pero no debe generalizarse para TODAS las propiedades. Agrega complejidad, dificultad para leer el código, problemas de seguridad de subprocesos ... para una optimización no perceptible en el 99% de los casos. Además, como SolutionYogi dijo como un comentario, el código del OP tiene errores, lo que demuestra que este patrón no es trivial de implementar y debe evitarse a menos que realmente se necesite una inicialización diferida.
ken2k
2
@DanielHilgarth Gracias por escribir todo (casi) todo mal al usar este patrón incondicionalmente. ¡Gran trabajo!
Alex
1
@DanielHilgarth Bueno, sí y no. La violación es el problema aquí, así que sí. Pero también 'no' porque POLS es estrictamente el principio de que probablemente no te sorprenda el código . Si Foo no estuvo expuesto fuera de su programa, es un riesgo que puede tomar o no. En este caso, casi puedo garantizar que eventualmente se sorprenderá , porque no controla la forma en que se accede a las propiedades. El riesgo se convirtió en un error y su argumento sobre el nullcaso se hizo mucho más fuerte. :-)
atlaste
49

Es una buena elección de diseño. Muy recomendable para el código de la biblioteca o las clases principales.

Es llamado por alguna "inicialización diferida" o "inicialización retrasada" y generalmente es considerado por todos como una buena opción de diseño.

Primero, si inicializa en la declaración de variables de nivel de clase o constructor, luego, cuando se construye su objeto, tiene la sobrecarga de crear un recurso que nunca se puede usar.

En segundo lugar, el recurso solo se crea si es necesario.

Tercero, evita que la basura recolecte un objeto que no se usó.

Por último, es más fácil manejar las excepciones de inicialización que pueden ocurrir en la propiedad que las excepciones que ocurren durante la inicialización de las variables de nivel de clase o el constructor.

Hay excepciones a esta regla.

En cuanto al argumento de rendimiento de la verificación adicional para la inicialización en la propiedad "get", es insignificante. La inicialización y disposición de un objeto es un golpe de rendimiento más significativo que una simple comprobación de puntero nulo con un salto.

Pautas de diseño para desarrollar bibliotecas de clases en http://msdn.microsoft.com/en-US/library/vstudio/ms229042.aspx

Respecto a Lazy<T>

La Lazy<T>clase genérica se creó exactamente para lo que quiere el póster, consulte Inicialización diferida en http://msdn.microsoft.com/en-us/library/dd997286(v=vs.100).aspx . Si tiene versiones anteriores de .NET, debe usar el patrón de código ilustrado en la pregunta. Este patrón de código se ha vuelto tan común que Microsoft consideró apropiado incluir una clase en las últimas bibliotecas .NET para facilitar la implementación del patrón. Además, si su implementación necesita seguridad de subprocesos, debe agregarla.

Tipos de datos primitivos y clases simples

Obviamente, no va a utilizar la inicialización diferida para el tipo de datos primitivos o el uso de clases simples como List<string>.

Antes de comentar sobre Lazy

Lazy<T> se introdujo en .NET 4.0, así que por favor no agregue otro comentario sobre esta clase.

Antes de comentar sobre micro optimizaciones

Cuando crea bibliotecas, debe considerar todas las optimizaciones. Por ejemplo, en las clases .NET verá matrices de bits utilizadas para las variables de clase booleana en todo el código para reducir el consumo de memoria y la fragmentación de la memoria, solo para nombrar dos "microoptimizaciones".

En cuanto a las interfaces de usuario

No va a utilizar la inicialización diferida para las clases que usa directamente la interfaz de usuario. La semana pasada pasé la mayor parte del día eliminando la carga diferida de ocho colecciones utilizadas en un modelo de vista para cuadros combinados. Tengo un LookupManagerque maneja la carga diferida y el almacenamiento en caché de las colecciones que necesita cualquier elemento de la interfaz de usuario.

"Setters"

Nunca he usado una propiedad set ("setters") para ninguna propiedad con carga diferida. Por lo tanto, nunca lo permitirías foo.Bar = null;. Si necesita establecer, Barentonces crearía un método llamado SetBar(Bar value)y no usaría la inicialización diferida

Colecciones

Las propiedades de la colección de clases siempre se inicializan cuando se declaran porque nunca deberían ser nulas.

Clases complejas

Déjame repetir esto de manera diferente, usas la inicialización diferida para clases complejas. Que suelen ser clases mal diseñadas.

Por último

Nunca dije que hiciera esto para todas las clases o en todos los casos. Es un mal habito.

Amissico
fuente
66
Si puede llamar a foo.Bar varias veces en diferentes subprocesos sin ninguna configuración de valores intermedios, pero obtiene valores diferentes, entonces tiene una clase pobre pésima.
Mentira Ryan
25
Creo que esta es una mala regla general sin muchas consideraciones. A menos que Bar sea un experto en recursos, esta es una micro optimización innecesaria. Y en el caso de que Bar requiera muchos recursos, existe el hilo seguro Lazy <T> integrado en .net.
Andrew Hanlon
10
"es más fácil manejar las excepciones de inicialización que pueden ocurrir en la propiedad que las excepciones que ocurren durante la inicialización de las variables de nivel de clase o el constructor". - Está bien, esto es una tontería. Si un objeto no se puede inicializar por alguna razón, quiero saber lo antes posible. Es decir, de inmediato cuando se construye. Hay buenos argumentos para usar la inicialización perezosa, pero no creo que sea una buena idea usarla de manera generalizada .
millimoose
20
Estoy realmente preocupado de que otros desarrolladores vean esta respuesta y piensen que esta es una buena práctica (oh, chico). Esta es una muy, muy mala práctica si la está utilizando incondicionalmente. Además de lo que ya se ha dicho, está haciendo la vida de todos mucho más difícil (para desarrolladores de clientes y desarrolladores de mantenimiento) por tan poca ganancia (si es que hay alguna). Debería escuchar a los profesionales: Donald Knuth, en la serie The Art of Computer Programming, dijo que "la optimización prematura es la raíz de todo mal". ¡Lo que estás haciendo no solo es malo, es diabólico!
Alex
44
Hay tantos indicadores que está eligiendo la respuesta incorrecta (y la decisión de programación incorrecta). Tienes más desventajas que profesionales en tu lista. Tienes más personas que responden en contra de eso. Los miembros más experimentados de este sitio (@BillK y @DanielHilgarth) que publicaron en esta pregunta están en contra. Tu compañero de trabajo ya te dijo que está mal. En serio, está mal! Si encuentro a uno de los desarrolladores de mi equipo (soy un líder de equipo) haciendo esto, tomará un tiempo de espera de 5 minutos y luego se le explicará por qué nunca debería hacer esto.
Alex
17

¿Considera implementar tal patrón usando Lazy<T>?

Además de la creación sencilla de objetos con carga lenta, obtiene seguridad de subprocesos mientras se inicializa el objeto:

Como otros dijeron, usted carga objetos perezosamente si realmente consumen muchos recursos o si lleva algún tiempo cargarlos durante el tiempo de construcción del objeto.

Matías Fidemraizer
fuente
Gracias, lo entiendo ahora y definitivamente lo investigaré Lazy<T>ahora, y me abstendré de usar la forma en que siempre solía hacerlo.
John Willemse
1
No obtienes seguridad de hilo mágico ... todavía tienes que pensarlo. Desde MSDN:Making the Lazy<T> object thread safe does not protect the lazily initialized object. If multiple threads can access the lazily initialized object, you must make its properties and methods safe for multithreaded access.
Eric J.
@EricJ. Por supuesto por supuesto. Solo obtiene seguridad de subprocesos al inicializar el objeto, pero más tarde debe lidiar con la sincronización como cualquier otro objeto.
Matías Fidemraizer
9

Creo que depende de lo que estés inicializando. Probablemente no lo haría para una lista ya que el costo de construcción es bastante pequeño, por lo que puede ir en el constructor. Pero si se tratara de una lista previamente poblada, probablemente no lo haría hasta que fuera necesaria por primera vez.

Básicamente, si el costo de la construcción supera el costo de hacer una verificación condicional en cada acceso, entonces crearlo de forma diferida. Si no, hazlo en el constructor.

Colin Mackay
fuente
¡Gracias! Eso tiene sentido.
John Willemse
9

La desventaja que puedo ver es que si quieres preguntar si Bars es nulo, nunca lo sería, y estarías creando la lista allí.

Luis tellez
fuente
No creo que sea un inconveniente.
Peter Porfy
¿Por qué es eso un inconveniente? Simplemente verifique con cualquiera en lugar de nulo. if (! Foo.Bars.Any ())
s.meijer
66
@PeterPorfy: Viola POLS . Lo pones null, pero no lo recuperas. Normalmente, asume que recupera el mismo valor que puso en una propiedad.
Daniel Hilgarth
@DanielHilgarth Gracias de nuevo. Ese es un argumento muy válido que no había considerado antes.
John Willemse
66
@ AMissico: No es un concepto inventado. De la misma manera que se espera que al presionar un botón al lado de una puerta delantera suene un timbre, se espera que las cosas que parecen propiedades se comporten como propiedades. Abrir una trampa debajo de tus pies es un comportamiento sorprendente, especialmente si el botón no está etiquetado como tal.
Bryan Boettcher
8

La instanciación / inicialización perezosa es un patrón perfectamente viable. Sin embargo, tenga en cuenta que, como regla general, los consumidores de su API no esperan que los captadores y definidores tomen un tiempo discernible del POV del usuario final (o que fallen).

Tormod
fuente
1
Estoy de acuerdo, y he editado mi pregunta un poco. Espero que una cadena completa de constructores subyacentes tome más tiempo que crear instancias de clases solo cuando sean necesarias.
John Willemse
8

Solo iba a poner un comentario sobre la respuesta de Daniel, pero honestamente no creo que llegue lo suficientemente lejos.

Aunque este es un patrón muy bueno para usar en ciertas situaciones (por ejemplo, cuando el objeto se inicializa desde la base de datos), es un hábito HORRIBLE para entrar.

Una de las mejores cosas de un objeto es que ofende un entorno seguro y confiable. El mejor caso es si crea tantos campos como sea posible "Final", llenándolos todos con el constructor. Esto hace que su clase sea bastante a prueba de balas. Permitir que los campos se cambien a través de setters es un poco menos, pero no es terrible. Por ejemplo:

clase SafeClass
{
    Nombre de cadena = "";
    Edad entera = 0;

    public void setName (String newName)
    {
        afirmar (nuevoNombre! = nulo)
        nombre = nuevoNombre;
    } // sigue este patrón para la edad
    ...
    public String toString () {
        Cadena s = "La clase segura tiene nombre:" + nombre + "y edad:" + edad
    }
}

Con su patrón, el método toString se vería así:

    if (nombre == nulo)
        lanzar nueva IllegalStateException ("¡SafeClass entró en un estado ilegal! el nombre es nulo")
    if (edad == nulo)
        lanzar una nueva IllegalStateException ("¡SafeClass entró en un estado ilegal! la edad es nula")

    public String toString () {
        Cadena s = "La clase segura tiene nombre:" + nombre + "y edad:" + edad
    }

No solo esto, sino que necesita verificaciones nulas en todas partes donde posiblemente pueda usar ese objeto en su clase (Fuera de su clase está a salvo debido a la verificación nula en el getter, pero debería usar principalmente los miembros de su clase dentro de la clase)

Además, su clase está perpetuamente en un estado incierto; por ejemplo, si decidiera hacer de esa clase una clase de hibernación agregando algunas anotaciones, ¿cómo lo haría?

Si toma una decisión basada en alguna micro-optomización sin requisitos y pruebas, es casi seguro que es una decisión equivocada. De hecho, existe una muy buena posibilidad de que su patrón realmente esté ralentizando el sistema incluso en las circunstancias más ideales porque la declaración if puede causar un fallo de predicción de ramificación en la CPU, lo que ralentizará las cosas muchas, muchas veces más que solo asigna un valor en el constructor a menos que el objeto que está creando sea bastante complejo o provenga de una fuente de datos remota.

Para ver un ejemplo del problema de predicción de brance (en el que está incurriendo repetidamente, no solo una vez), vea la primera respuesta a esta increíble pregunta: ¿Por qué es más rápido procesar una matriz ordenada que una matriz sin clasificar?

Bill K
fuente
Gracias por tu contribución. En mi caso, ninguna de las clases tiene ningún método que deba verificar nulo, por lo que no es un problema. Tomaré en consideración tus otras objeciones.
John Willemse
Realmente no entiendo eso. Implica que no estás usando a tus miembros en las clases donde están almacenados, que solo estás usando las clases como estructuras de datos. Si ese es el caso, es posible que desee leer javaworld.com/javaworld/jw-01-2004/jw-0102-toolbox.html que tiene una buena descripción de cómo se puede mejorar su código evitando la manipulación externa del estado del objeto. Si los está manipulando internamente, ¿cómo lo hace sin verificar nulo todo repetidamente?
Bill K
Algunas partes de esta respuesta son buenas, pero algunas partes parecen artificiales. Normalmente al usar este patrón, toString()llamaría getName(), no usar namedirectamente.
Izkata
@ Bill Sí, las clases son una gran estructura de datos. Todo el trabajo se realiza en clases estáticas. Revisaré el artículo en el enlace. ¡Gracias!
John Willemse
1
@izkata En realidad, dentro de una clase, parece ser una sacudida en cuanto al clima en el que usas un captador o no, la mayoría de los lugares en los que he trabajado utilizan al miembro directamente. Aparte de eso, si siempre está usando un captador, el método if () es aún más dañino porque las fallas de predicción de ramificación ocurrirán con mayor frecuencia Y debido a la ramificación, el tiempo de ejecución probablemente tendrá más problemas para inyectar al captador. Sin embargo, todo es discutible, con la revelación de John de que son estructuras de datos y clases estáticas, eso es lo que más me preocuparía.
Bill K
4

Permítanme agregar un punto más a muchos puntos buenos hechos por otros ...

El depurador evaluará ( por defecto ) las propiedades al recorrer el código, lo que podría crear una instancia Barmás pronto de lo que normalmente sucedería simplemente ejecutando el código. En otras palabras, el mero acto de depuración está cambiando la ejecución del programa.

Esto puede o no ser un problema (dependiendo de los efectos secundarios), pero es algo a tener en cuenta.

Branko Dimitrijevic
fuente
2

¿Estás seguro de que Foo debería estar instanciando algo?

A mí me parece maloliente (aunque no necesariamente incorrecto ) dejar que Foo cree una instancia de nada. A menos que el propósito expreso de Foo sea ser una fábrica, no debe crear instancias de sus propios colaboradores, sino inyectarlos en su constructor .

Sin embargo, si el propósito de ser de Foo es crear instancias de tipo Bar, entonces no veo nada de malo en hacerlo perezosamente.

KaptajnKold
fuente
44
@BenjaminGruenbaum No, en realidad no. Y respetuosamente, incluso si fuera así, ¿qué punto intentabas hacer?
KaptajnKold