¿Por qué no se convirtió en un patrón común el uso de setters en el constructor?

23

Los accesorios y modificadores (también conocidos como setters y getters) son útiles por tres razones principales:

  1. Restringen el acceso a las variables.
    • Por ejemplo, se puede acceder a una variable, pero no modificarla.
  2. Validan los parámetros.
  3. Pueden causar algunos efectos secundarios.

Las universidades, los cursos en línea, los tutoriales, los artículos de blog y los ejemplos de códigos en la web hacen hincapié en la importancia de los accesores y modificadores, casi se sienten como un "must have" para el código hoy en día. Entonces uno puede encontrarlos incluso cuando no proporcionan ningún valor adicional, como el código a continuación.

public class Cat {
    private int age;

    public int getAge() {
        return this.age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

Dicho esto, es muy común encontrar modificadores más útiles, aquellos que realmente validan los parámetros y arrojan una excepción o devuelven un valor booleano si se ha proporcionado una entrada no válida, algo como esto:

/**
 * Sets the age for the current cat
 * @param age an integer with the valid values between 0 and 25
 * @return true if value has been assigned and false if the parameter is invalid
 */
public boolean setAge(int age) {
    //Validate your parameters, valid age for a cat is between 0 and 25 years
    if(age > 0 && age < 25) {
        this.age = age;
        return true;
    }
    return false;
}

Pero incluso entonces, casi nunca veo que los modificadores se hayan llamado desde un constructor, por lo que el ejemplo más común de una clase simple que enfrento es este:

public class Cat {
    private int age;

    public Cat(int age) {
        this.age = age;
    }

    public int getAge() {
        return this.age;
    }

    /**
     * Sets the age for the current cat
     * @param age an integer with the valid values between 0 and 25
     * @return true if value has been assigned and false if the parameter is invalid
     */
    public boolean setAge(int age) {
        //Validate your parameters, valid age for a cat is between 0 and 25 years
        if(age > 0 && age < 25) {
            this.age = age;
            return true;
        }
        return false;
    }

}

Pero uno pensaría que este segundo enfoque es mucho más seguro:

public class Cat {
    private int age;

    public Cat(int age) {
        //Use the modifier instead of assigning the value directly.
        setAge(age);
    }

    public int getAge() {
        return this.age;
    }

    /**
     * Sets the age for the current cat
     * @param age an integer with the valid values between 0 and 25
     * @return true if value has been assigned and false if the parameter is invalid
     */
    public boolean setAge(int age) {
        //Validate your parameters, valid age for a cat is between 0 and 25 years
        if(age > 0 && age < 25) {
            this.age = age;
            return true;
        }
        return false;
    }

}

¿Ves un patrón similar en tu experiencia o solo soy yo desafortunado? Y si lo haces, ¿qué crees que está causando eso? ¿Existe una desventaja obvia para usar modificadores de los constructores o simplemente se consideran más seguros? ¿Es algo más?

Vlad Spreys
fuente
1
@stg, gracias, punto interesante! ¿Podría ampliarlo y ponerlo en una respuesta con un ejemplo de algo malo que tal vez sucedería?
Vlad Spreys
8
@stg En realidad, es un antipatrón usar métodos reemplazables en el constructor y muchas herramientas de calidad de código lo señalarán como un error. No sabe lo que hará la versión anulada y podría hacer cosas desagradables como dejar escapar "esto" antes de que el constructor termine, lo que puede conducir a todo tipo de problemas extraños.
Michał Kosmulski
1
@gnat: No creo que esto sea un duplicado de ¿Deberían los métodos de una clase llamar a sus propios captadores y establecedores? , debido a la naturaleza de las llamadas de constructor que se invocan en un objeto que no está completamente formado.
Greg Burghardt
3
Tenga en cuenta también que su "validación" simplemente deja la edad sin inicializar (o cero, o ... algo más, dependiendo del idioma). Si desea que el constructor falle, debe verificar el valor de retorno y lanzar una excepción en caso de error. Tal como está, su validación acaba de introducir un error diferente.
Inútil el

Respuestas:

37

Razonamiento filosófico muy general

Por lo general, pedimos que un constructor proporcione (como condiciones posteriores) algunas garantías sobre el estado del objeto construido.

Por lo general, también esperamos que los métodos de instancia puedan asumir (como condiciones previas) que estas garantías ya se mantienen cuando se llaman, y solo tienen que asegurarse de no romperlas.

Llamar a un método de instancia desde el interior del constructor significa que algunas o todas esas garantías aún no se han establecido, lo que dificulta razonar si las condiciones previas del método de instancia se cumplen. Incluso si lo hace bien, puede ser muy frágil, por ejemplo. reordenando llamadas a métodos de instancia u otras operaciones.

Los idiomas también varían en la forma en que resuelven las llamadas a los métodos de instancia que se heredan de las clases base / anuladas por las subclases, mientras el constructor todavía se está ejecutando. Esto agrega otra capa de complejidad.

Ejemplos específicos

  1. Tu propio ejemplo de cómo crees que esto debería verse está equivocado:

    public Cat(int age) {
        //Use the modifier instead of assigning the value directly.
        setAge(age);
    }

    esto no verifica el valor de retorno de setAge. Aparentemente llamar al setter no es garantía de corrección después de todo.

  2. Errores muy fáciles como depender del orden de inicialización, como:

    class Cat {
      private Logger m_log;
      private int m_age;
    
      public void setAge(int age) {
        // FIXME temporary debug logging
        m_log.write("=== DEBUG: setting age ===");
        m_age = age;
      }
    
      public Cat(int age, Logger log) {
        setAge(age);
        m_log = log;
      }
    };

    donde mi registro temporal lo rompió todo. Whoops!

También hay lenguajes como C ++ donde llamar a un setter desde el constructor significa una inicialización predeterminada desperdiciada (que para algunas variables miembro al menos vale la pena evitar)

Una propuesta simple

Es cierto que la mayoría del código no está escrito así, pero si desea mantener su constructor limpio y predecible, y aún así reutilizar su lógica previa y posterior a la condición, la mejor solución es:

class Cat {
  private int m_age;
  private static void checkAge(int age) {
    if (age > 25) throw CatTooOldError(age);
  }

  public void setAge(int age) {
    checkAge(age);
    m_age = age;
  }

  public Cat(int age) {
    checkAge(age);
    m_age = age;
  }
};

o incluso mejor, si es posible: codifique la restricción en el tipo de propiedad y haga que valide su propio valor en la asignación:

class Cat {
  private Constrained<int, 25> m_age;

  public void setAge(int age) {
    m_age = age;
  }

  public Cat(int age) {
    m_age = age;
  }
};

Y, por último, para completar, un contenedor autovalidado en C ++. Tenga en cuenta que aunque todavía está haciendo la tediosa validación, debido a que esta clase no hace nada más , es relativamente fácil verificar

template <typename T, T MAX, T MIN=T{}>
class Constrained {
    T val_;
    static T validate(T v) {
        if (MIN <= v && v <= MAX) return v;
        throw std::runtime_error("oops");
    }
public:
    Constrained() : val_(MIN) {}
    explicit Constrained(T v) : val_(validate(v)) {}

    Constrained& operator= (T v) { val_ = validate(v); return *this; }
    operator T() { return val_; }
};

OK, no está realmente completo, he omitido varios constructores y asignaciones de copiar y mover.

Inútil
fuente
44
Respuesta muy poco votada! Permítanme agregar solo porque el OP ha visto la situación descrita en un libro de texto no lo convierte en una buena solución. Si hay dos formas en una clase para establecer un atributo (por constructor y definidor), y uno quiere imponer una restricción sobre ese atributo, ambas formas deben verificar la restricción, de lo contrario es un error de diseño .
Doc Brown
Entonces, ¿consideraría usar métodos de establecimiento en una mala práctica del constructor si no hay 1) ninguna lógica en los establecedores, o 2) la lógica en los establecedores es independiente del estado? No lo hago a menudo, pero personalmente he usado métodos setter en un constructor exactamente por la última razón (cuando hay algún tipo de condición en setter que siempre debe aplicarse a la variable miembro
Chris Maggiulli
Si hay algún tipo de condición que siempre debe aplicarse, eso es lógico en el setter. Ciertamente creo que es mejor si es posible codificar esta lógica en el miembro, por lo que se ve obligado a ser autónomo y no puede adquirir dependencias del resto de la clase.
Inútil
13

Cuando se construye un objeto, por definición no está completamente formado. Considere cómo el setter que proporcionó:

public boolean setAge(int age) {
    //Validate your parameters, valid age for a cat is between 0 and 25 years
    if(age > 0 && age < 25) {
        this.age = age;
        return true;
    }
    return false;
}

¿Qué sucede si parte de la validación setAge()incluye una verificación de la agepropiedad para garantizar que el objeto solo pueda aumentar en edad? Esa condición podría verse así:

if (age > this.age && age < 25) {
    //...

Parece un cambio bastante inocente, ya que los gatos solo pueden moverse a través del tiempo en una dirección. El único problema es que no puede usarlo para establecer el valor inicial de this.ageporque supone que this.ageya tiene un valor válido.

Evitar los setters en los constructores deja en claro que lo único que está haciendo el constructor es establecer la variable de instancia y omitir cualquier otro comportamiento que ocurra en el setter.

Caleb
fuente
OK ... pero, si en realidad no prueba la construcción de objetos y expone los posibles errores, hay posibles errores de cualquier manera . Y ahora tienes dos problemas: tienes ese error potencial oculto y tienes que aplicar controles de rango de edad en dos lugares.
svidgen
No hay problema, ya que en Java / C # todos los campos se ponen a cero antes de la invocación del constructor.
Deduplicador
2
Sí, llamar a métodos de instancia desde constructores puede ser difícil de razonar, y generalmente no se prefiere. Ahora, si desea mover su lógica de validación a un método privado, final de clase / estático, y llamarlo tanto desde el constructor como desde el setter, estaría bien.
Inútil el
1
No, los métodos que no son de instancia evitan el truco de los métodos de instancia, porque no tocan una instancia parcialmente construida. Por supuesto, aún debe verificar el resultado de la validación para decidir si la construcción tuvo éxito.
Inútil el
1
Esta respuesta es en mi humilde opinión falta el punto. "Cuando se está construyendo un objeto, por definición no está completamente formado" , en el medio del proceso de construcción, por supuesto, pero cuando se realiza el constructor, cualquier restricción intencionada sobre los atributos debe mantenerse, de lo contrario se puede eludir esa restricción. Yo llamaría a eso un grave defecto de diseño.
Doc Brown
6

Algunas buenas respuestas aquí ya, pero hasta ahora nadie parecía haber notado que estás preguntando aquí dos cosas en una, lo que causa cierta confusión. En mi humilde opinión, tu publicación se ve mejor como dos preguntas separadas :

  1. si hay una validación de una restricción en un setter, ¿no debería estar también en el constructor de la clase para asegurarse de que no se pueda omitir?

  2. ¿Por qué no llamar al constructor directamente para este propósito desde el constructor?

La respuesta a las primeras preguntas es claramente "sí". Un constructor, cuando no arroja una excepción, debe dejar el objeto en un estado válido, y si ciertos valores están prohibidos para ciertos atributos, entonces no tiene ningún sentido dejar que el ctor evite esto.

Sin embargo, la respuesta a la segunda pregunta suele ser "no", siempre y cuando no tome medidas para evitar anular el setter en una clase derivada. Por lo tanto, la mejor alternativa es implementar la validación de restricciones en un método privado que se pueda reutilizar tanto desde el setter como desde el constructor. No voy a repetir aquí el ejemplo @Useless, que muestra exactamente este diseño.

Doc Brown
fuente
Todavía no entiendo por qué es mejor extraer la lógica del setter para que el constructor pueda usarla. ... ¿Cómo es esto realmente diferente de simplemente llamar a los setters en un orden razonable? Especialmente teniendo en cuenta que, si sus setters son tan simples como "deberían" ser, la única parte del setter que su constructor, en este momento, no está invocando es la configuración real del campo subyacente ...
svidgen
2
@svidgen: vea el ejemplo de JimmyJames: en resumen, no es una buena idea usar métodos reemplazables en un ctor, que causarán problemas. Puede usar el setter en ctor si es final y no llama a ningún otro método reemplazable. Además, separar la validación de la forma de manejarla cuando falla le ofrece la oportunidad de manejarla de manera diferente en el ctor y en el setter.
Doc Brown
Bueno, ¡ahora estoy menos convencido! Pero, gracias por señalarme la otra respuesta ...
svidgen
2
Con un orden razonable te refieres a una dependencia de orden frágil e invisible que se rompe fácilmente con cambios de aspecto inocente , ¿verdad?
Inútil el
@useless bueno, no ... Quiero decir, es curioso que sugieras que el orden en que se ejecuta tu código no debería importar. pero, ese no era realmente el punto. Más allá de los ejemplos artificiales, simplemente no puedo recordar una instancia en mi experiencia en la que pensé que era una buena idea tener validaciones de propiedades cruzadas en un setter; ese tipo de cosas conduce necesariamente a requisitos de orden de invocación "invisibles" . Independientemente de si esas invocaciones se producen en un constructor ..
svidgen
2

Aquí hay un poco de código Java que muestra los tipos de problemas con los que puede encontrarse utilizando métodos no finales en su constructor:

import java.util.regex.Pattern;

public class Problem
{
  public static final void main(String... args)
  {
    Problem problem = new Problem("John Smith");
    Problem next = new NextProblem("John Smith", " ");

    System.out.println(problem.getName());
    System.out.println(next.getName());
  }

  private String name;

  Problem(String name)
  {
    setName(name);
  }

  void setName(String name)
  {
    this.name = name;
  }

  String getName()
  {
    return name;
  }
}

class NextProblem extends Problem
{
  private String firstName;
  private String lastName;
  private final String delimiter;

  NextProblem(String name, String delimiter)
  {
    super(name);

    this.delimiter = delimiter;
  }

  void setName(String name)
  {
    String[] parts = name.split(Pattern.quote(delimiter));

    this.firstName = parts[0];
    this.lastName = parts[1];
  }

  String getName()
  {
    return firstName + " " + lastName;
  }
}

Cuando ejecuto esto, obtengo un NPE en el constructor NextProblem. Este es un ejemplo trivial, por supuesto, pero las cosas pueden complicarse rápidamente si tiene múltiples niveles de herencia.

Creo que la razón más importante por la que esto no se hizo común es porque hace que el código sea un poco más difícil de entender. Personalmente, casi nunca tengo métodos de establecimiento y mis variables miembro son casi siempre (juego de palabras) final. Por eso, los valores deben establecerse en el constructor. Si usa objetos inmutables (y hay muchas buenas razones para hacerlo) la pregunta es discutible.

En cualquier caso, es un buen objetivo reutilizar la validación u otra lógica y puede ponerla en un método estático e invocarla tanto desde el constructor como desde el instalador.

JimmyJames
fuente
¿Cómo es esto un problema de usar setters en el constructor en lugar de un problema de herencia mal implementada? En otras palabras, si estás invalidando efectivamente el constructor base, ¿por qué lo llamarías?
svidgen
1
Creo que el punto es que anular un setter no debería invalidar al constructor.
Chris Wohlert
@ChrisWohlert Ok ... pero, si cambias la lógica de tu setter para entrar en conflicto con la lógica de tu constructor, es un problema independientemente de si tu constructor usa el setter. O falla en el momento de la construcción, falla más tarde o falla de manera invisible (b / c ha omitido las reglas de su negocio).
svidgen
A menudo no sabes cómo se implementa el Constructor de la clase base (podría estar en una biblioteca de terceros en algún lugar). Sin embargo, anular un método reemplazable no debería romper el contrato de la implementación base (y podría decirse que este ejemplo lo hace al no establecer el namecampo).
Hulk
@svidgen el problema aquí es que llamar a métodos reemplazables desde el constructor reduce la utilidad de anularlos; no puede usar ningún campo de la clase secundaria en las versiones anuladas porque es posible que aún no se hayan inicializado. Lo que es peor, no debe pasar una thisreferencia a algún otro método, porque aún no puede estar seguro de que esté completamente inicializado.
Hulk
1

¡Depende!

Si está realizando una validación simple o emitiendo efectos secundarios traviesos en el setter que necesitan ser alcanzados cada vez que se establece la propiedad: use el setter. La alternativa es generalmente extraer la lógica del setter del setter e invocar el nuevo método seguido por el set actual tanto del setter como del constructor, ¡lo cual es efectivamente lo mismo que usar el setter! (Excepto que ahora está manteniendo su setter de dos líneas, ciertamente pequeño, en dos lugares).

Sin embargo , si necesita realizar operaciones complejas para organizar el objeto antes de que un setter particular (¡o dos!) Se pueda ejecutar con éxito, no use el setter.

Y en cualquier caso, tenga casos de prueba . Independientemente de la ruta que tome, si tiene pruebas de unidad, tendrá una buena oportunidad de exponer los peligros asociados con cualquiera de las opciones.


También agregaré, si mi memoria de tan lejos es remotamente precisa, este es el tipo de razonamiento que también me enseñaron en la universidad. Y no está nada fuera de línea con proyectos exitosos en los que he tenido el privilegio de trabajar.

Si tuviera que adivinar, me perdí un memo de "código limpio" en algún momento que identificaba algunos errores típicos que pueden surgir del uso de setters en un constructor. Pero, por mi parte, no he sido víctima de tales errores ...

Por lo tanto, argumentaría que esta decisión en particular no necesita ser radical y dogmática.

svidgen
fuente