Cómo advertir a otros programadores sobre la implementación de clases

96

Estoy escribiendo clases que "deben usarse de una manera específica" (supongo que todas las clases deben ...).

Por ejemplo, puedo crear la fooManagerclase, lo que requiere una llamada a, por ejemplo, Initialize(string,string). Y, para llevar el ejemplo un poco más lejos, la clase sería inútil si no escuchamos su ThisHappenedacción.

Mi punto es que la clase que estoy escribiendo requiere llamadas a métodos. Pero se compilará bien si no llama a esos métodos y terminará con un nuevo FooManager vacío. En algún momento, no funcionará o se bloqueará, dependiendo de la clase y de lo que haga. El programador que implementa mi clase obviamente miraría dentro y se daría cuenta de "¡Oh, no llamé Initialize!", Y estaría bien.

Pero eso no me gusta. Lo que idealmente querría es que el código NO se compile si no se llamó al método; Supongo que eso simplemente no es posible. O algo que sería inmediatamente visible y claro.

Me encuentro preocupado por el enfoque actual que tengo aquí, que es el siguiente:

Agregue un valor booleano privado en la clase y verifique en todos los lugares necesarios si la clase se inicializa; si no, lanzaré una excepción diciendo "La clase no se inicializó, ¿estás seguro de que estás llamando .Initialize(string,string)?".

Estoy bastante de acuerdo con ese enfoque, pero conduce a una gran cantidad de código que se compila y, al final, no es necesario para el usuario final.

Además, a veces es incluso más código cuando hay más métodos que simplemente Initiliazellamar. Trato de mantener mis clases con no demasiados métodos / acciones públicas, pero eso no resuelve el problema, solo lo mantengo razonable.

Lo que estoy buscando aquí es:

  • ¿Es correcto mi enfoque?
  • Hay alguno mejor?
  • ¿Qué hacen / aconsejan ustedes?
  • ¿Estoy tratando de resolver un problema no? Los colegas me han dicho que es el programador el que debe verificar la clase antes de intentar usarla. Respetuosamente no estoy de acuerdo, pero creo que ese es otro asunto.

En pocas palabras, estoy tratando de encontrar una manera de nunca olvidar implementar llamadas cuando esa clase se reutiliza más tarde, o por otra persona.

Aclaraciones

Para aclarar muchas preguntas aquí:

  • Definitivamente no solo estoy hablando de la parte de inicialización de una clase, sino que es toda la vida. Evite que los colegas llamen a un método dos veces, asegurándose de que llamen X antes que Y, etc. Cualquier cosa que termine siendo obligatoria y en la documentación, pero que me gustaría en código y lo más simple y pequeño posible. Realmente me gustó la idea de Asserts, aunque estoy bastante seguro de que necesitaré mezclar algunas otras ideas ya que Asserts no siempre será posible.

  • Estoy usando el lenguaje C #! ¿Cómo no mencioné eso? Estoy en un entorno de Xamarin y estoy creando aplicaciones móviles que usualmente usan entre 6 y 9 proyectos en una solución, incluidos proyectos de PCL, iOS, Android y Windows. He sido desarrollador durante aproximadamente un año y medio (escuela y trabajo combinados), de ahí mis declaraciones / preguntas a veces ridículas. Probablemente, todo eso es irrelevante aquí, pero demasiada información no siempre es algo malo.

  • No siempre puedo poner todo lo que es obligatorio en el constructor, debido a las restricciones de la plataforma y al uso de Inyección de dependencias, tener parámetros distintos de Interfaces está fuera de la mesa. O tal vez mi conocimiento no es suficiente, lo cual es muy posible. La mayoría de las veces no es un problema de inicialización, sino más

¿Cómo puedo asegurarme de que se haya registrado en ese evento?

¿Cómo puedo asegurarme de que no se olvidó de "detener el proceso en algún momento"?

Aquí recuerdo una clase de obtención de anuncios. Mientras la vista donde está visible el Anuncio esté visible, la clase buscará un nuevo anuncio cada minuto. Esa clase necesita una vista cuando se construye donde puede mostrar el anuncio, que obviamente podría ir en un parámetro. Pero una vez que la vista se ha ido, se debe llamar a StopFetching (). De lo contrario, la clase seguiría buscando anuncios para una vista que ni siquiera está allí, y eso es malo.

Además, esa clase tiene eventos que deben ser escuchados, como "AdClicked", por ejemplo. Todo funciona bien si no se escucha, pero perdemos el seguimiento de la analítica allí si los grifos no están registrados. Sin embargo, el anuncio aún funciona, por lo que el usuario y el desarrollador no verán la diferencia, y los análisis solo tendrán datos incorrectos. Es necesario evitarlo, pero no estoy seguro de cómo los desarrolladores pueden saber que deben registrarse en el evento tao. Sin embargo, ese es un ejemplo simplificado, pero la idea está ahí, "¡asegúrate de que use la Acción pública que está disponible" y en los momentos correctos, por supuesto!

Gil Sand
fuente
13
En general, no es posible garantizar que las llamadas a los métodos de una clase se realicen en un orden específico . Este es uno de muchos, muchos problemas que son equivalentes al problema de detención. Aunque sería posible hacer que el incumplimiento sea un error en tiempo de compilación en algunos casos, no existe una solución general. Es presumiblemente por eso que pocos idiomas admiten construcciones que le permitirían diagnosticar al menos los casos obvios, a pesar de que el análisis del flujo de datos se ha vuelto bastante sofisticado.
Kilian Foth
55
La respuesta a la otra pregunta es irrelevante, la pregunta no es la misma, por lo tanto, son preguntas diferentes. Si alguien busca esa discusión, no escribiría el título de la otra pregunta.
Gil Sand
66
¿Qué impide unir el contenido de initialize en el ctor? ¿Debe initializehacerse la llamada tarde después de la creación del objeto? ¿Sería el ctor demasiado "arriesgado" en el sentido de que podría generar excepciones y romper la cadena de creación?
Visto el
77
Este problema se llama acoplamiento temporal . Si puede, trate de evitar poner el objeto en un estado no válido lanzando excepciones en el constructor cuando encuentre entradas no válidas, de esa manera nunca pasará la llamada newsi el objeto no está listo para ser utilizado. Confiar en un método de inicialización seguramente volverá a atormentarte y debe evitarse a menos que sea absolutamente necesario, al menos esa es mi experiencia.
Seralizar el

Respuestas:

161

En tales casos, es mejor usar el sistema de tipos de su idioma para ayudarlo con la inicialización adecuada. ¿Cómo podemos evitar que FooManagerse use un sin ser inicializado? Al evitar que FooManagerse cree un archivo sin la información necesaria para inicializarlo correctamente. En particular, toda inicialización es responsabilidad del constructor. Nunca debe permitir que su constructor cree un objeto en un estado ilegal.

Pero las personas que llaman necesitan construir un FooManagerantes de que puedan inicializarlo, por ejemplo, porque FooManagerse pasa como una dependencia.

No cree un FooManagersi no tiene uno. En cambio, lo que puede hacer es pasar un objeto que le permita recuperar uno completamente construido FooManager, pero solo con la información de inicialización. (En la programación funcional, sugiero que use una aplicación parcial para el constructor). Por ejemplo:

ctorArgs = ...;
getFooManager = (initInfo) -> new FooManager(ctorArgs, initInfo);
...
getFooManager(myInitInfo).fooMethod();

El problema con esto es que debe proporcionar la información de inicio cada vez que accede al FooManager.

Si es necesario en su idioma, puede ajustar la getFooManager()operación en una clase de fábrica o de constructor.

Realmente quiero hacer comprobaciones en tiempo de ejecución de que initialize()se llamó al método, en lugar de utilizar una solución a nivel de sistema de tipo.

Es posible encontrar un compromiso. Creamos una clase de contenedor MaybeInitializedFooManagerque tiene un get()método que devuelve el FooManager, pero arroja si el FooManagerno se inicializó por completo. Esto solo funciona si la inicialización se realiza a través del contenedor, o si hay un FooManager#isInitialized()método.

class MaybeInitializedFooManager {
  private final FooManager fooManager;

  public MaybeInitializedFooManager(CtorArgs ctorArgs) {
    fooManager = new FooManager(ctorArgs);
  }

  public FooManager initialize(InitArgs initArgs) {
    fooManager.initialize(initArgs);
    return fooManager;
  }

  public FooManager get() {
    if (fooManager.isInitialized()) return fooManager;
    throw ...;
  }
}

No quiero cambiar la API de mi clase.

En ese caso, querrás evitar los if (!initialized) throw;condicionales en todos y cada uno de los métodos. Afortunadamente, hay un patrón simple para resolver esto.

El objeto que proporciona a los usuarios es solo un shell vacío que delega todas las llamadas a un objeto de implementación. Por defecto, el objeto de implementación arroja un error para cada método que no se inicializó. Sin embargo, el initialize()método reemplaza el objeto de implementación con un objeto completamente construido.

class FooManager {
  private CtorArgs ctorArgs;
  private Impl impl;

  public FooManager(CtorArgs ctorArgs) {
    this.ctorArgs = ctorArgs;
    this.impl = new UninitializedImpl();
  }

  public void initialize(InitArgs initArgs) {
    impl = new MainImpl(ctorArgs, initArgs);
  }

  public X foo() { return impl.foo(); }
  public Y bar() { return impl.bar(); }
}

interface Impl {
  X foo();
  Y bar();
}

class UninitializedImpl implements Impl {
  public X foo() { throw ...; }
  public Y bar() { throw ...; }
}

class MainImpl implements Impl {
  public MainImpl(CtorArgs c, InitArgs i);
  public X foo() { ... }
  public Y bar() { ... }
}

Esto extrae el comportamiento principal de la clase en el MainImpl.

amon
fuente
99
Me gustan las dos primeras soluciones (+1), pero no creo que el último fragmento con su repetición de los métodos en FooManagery UninitialisedImples mejor que repetir if (!initialized) throw;.
Bergi
3
@Bergi A algunas personas les encantan las clases demasiado. Aunque si FooManagertiene muchos métodos, podría ser más fácil que potencialmente olvidar algunos de los if (!initialized)controles. Pero en ese caso, probablemente debería preferir dividir la clase.
user253751
1
A MaybeInitializedFoo tampoco me parece mejor que un Foo inicializado, pero hace +1 por dar algunas opciones / ideas.
Matthew James Briggs
77
+1 La fábrica es la opción correcta. No puede mejorar un diseño sin cambiarlo, por lo que en mi humilde opinión, la última parte de la respuesta sobre cómo hacer la mitad es que no va a ayudar al OP.
Nathan Cooper
66
@Bergi He encontrado que la técnica presentada en la última sección es tremendamente útil (ver también la técnica de refactorización "Reemplazar condicionales a través del polimorfismo" y el Patrón de estado). Es fácil olvidar la verificación en un método si usamos un if / else, es mucho más difícil olvidar implementar un método requerido por la interfaz. Lo más importante, MainImpl corresponde exactamente a un objeto donde el método de inicialización se fusiona con el constructor. Esto significa que la implementación de MainImpl puede disfrutar de las garantías más sólidas que esto ofrece, lo que simplifica el código principal. …
amon
79

La forma más efectiva y útil de evitar que los clientes "usen mal" un objeto es hacerlo imposible.

La solución más simple es fusionarse Initializecon el constructor. De esta forma, el objeto nunca estará disponible para el cliente en el estado no inicializado, por lo que el error no es posible. En caso de que no pueda hacer la inicialización en el propio constructor, puede crear un método de fábrica. Por ejemplo, si su clase requiere que se registren ciertos eventos, podría requerir el detector de eventos como un parámetro en el constructor o en la firma del método de fábrica.

Si necesita poder acceder al objeto no inicializado antes de la inicialización, entonces podría tener los dos estados implementados como clases separadas, por lo que comenzará con una UnitializedFooManagerinstancia, que tiene un Initialize(...)método que devuelve InitializedFooManager. Los métodos que solo se pueden invocar en el estado inicializado solo existen en InitializedFooManager. Este enfoque puede extenderse a múltiples estados si es necesario.

En comparación con las excepciones de tiempo de ejecución, es más trabajo representar estados como clases distintas, pero también le brinda garantías en tiempo de compilación de que no está llamando a métodos que no son válidos para el estado del objeto, y documenta las transiciones de estado más claramente en el código.

Pero, en términos más generales, la solución ideal es diseñar sus clases y métodos sin restricciones, como requerirle que llame a los métodos en ciertos momentos y en un cierto orden. Puede que no siempre sea posible, pero se puede evitar en muchos casos utilizando el patrón apropiado.

Si tiene un acoplamiento temporal complejo (se debe llamar a varios métodos en un cierto orden), una solución podría ser utilizar la inversión de control , de modo que cree una clase que llame a los métodos en el orden apropiado, pero use métodos de plantilla o eventos para permitir que el cliente realice operaciones personalizadas en las etapas apropiadas del flujo. De esa manera, la responsabilidad de realizar operaciones en el orden correcto se transfiere del cliente a la clase misma.

Un ejemplo simple: tiene un objeto Fileque le permite leer desde un archivo. Sin embargo, el cliente debe llamar Openantes de que se pueda llamar al ReadLinemétodo, y debe recordar llamar siempre Close(incluso si ocurre una excepción) después de lo cual el ReadLinemétodo ya no se debe llamar. Este es el acoplamiento temporal. Se puede evitar teniendo un único método que tome una devolución de llamada o delegue como argumento. El método logra abrir el archivo, llamar a la devolución de llamada y luego cerrar el archivo. Puede pasar una interfaz distinta con un Readmétodo a la devolución de llamada. De esta forma, no es posible que el cliente olvide llamar a los métodos en el orden correcto.

Interfaz con acoplamiento temporal:

class File {
     /// Must be called on a closed file.
     /// Remember to always call Close() when you are finished
     public void Open();

     /// must be called on on open file
     public string ReadLine();

     /// must be called on an open file
     public void Close();
}

Sin acoplamiento temporal:

class File {
    /// Opens the file, executes the callback, and closes the file again.
    public void Consume(Action<IOpenFile> callback);
}

interface IOpenFile {
    string ReadLine();
}

Una solución más pesada sería definir Filecomo una clase abstracta que requiere que implemente un método (protegido) que se ejecutará en el archivo abierto. Esto se llama un patrón de método de plantilla .

abstract class File {
    /// Opens the file, executes ConsumeOpenFile(), and closes the file again.
    public void Consume();

    /// override this
    abstract protected ConsumeOpenFile();

    /// call this from your ConsumeOpenFile() implementation
    protected string ReadLine();
}

La ventaja es la misma: el cliente no tiene que recordar llamar a los métodos en un orden determinado. Simplemente no es posible llamar a los métodos en el orden incorrecto.

JacquesB
fuente
2
También recuerdo casos en los que simplemente no era posible pasar del constructor, pero no tengo un ejemplo que mostrar en este momento
Gil Sand
2
El ejemplo ahora describe un patrón de fábrica, pero la primera oración en realidad describe el paradigma RAII . Entonces, ¿cuál se supone que recomienda esta respuesta?
Ext3h
11
@ Ext3h No creo que el patrón de fábrica y RAII sean mutuamente excluyentes.
Pieter B
@PieterB No, no lo son, una fábrica puede garantizar RAII. Pero solo con la implicación adicional de que los argumentos de plantilla / constructor (llamados UnitializedFooManager) no deben compartir un estado con las instancias ( InitializedFooManager), como lo Initialize(...)ha hecho realmente la Instantiate(...)semántica. De lo contrario, este ejemplo genera nuevos problemas si un desarrollador utiliza la misma plantilla dos veces. Y eso tampoco es posible prevenir / validar estáticamente si el lenguaje no admite explícitamente la movesemántica para consumir la plantilla de manera confiable.
Ext3h
2
@ Ext3h: recomiendo la primera solución ya que es la más simple. Pero si el cliente necesita poder acceder al objeto antes de llamar a Initialize, entonces no puede usarlo, pero podría usar la segunda solución.
JacquesB
39

Normalmente solo verificaría la inicialización y arrojaría (por ejemplo) un IllegalStateExceptionsi intentas usarlo mientras no está inicializado.

Sin embargo, si desea estar seguro en tiempo de compilación (y eso es loable y preferible), ¿por qué no tratar la inicialización como un método de fábrica que devuelve un objeto construido e inicializado, por ejemplo?

ComponentBuilder builder = new ComponentBuilder();
Component forClients = builder.initialise(); // the 'Component' is what you give your clients

y por lo que el control de la creación de objetos y ciclo de vida, y sus clientes obtener el Componentcomo resultado de su inicialización. Es efectivamente una instanciación perezosa.

Brian Agnew
fuente
1
Buena respuesta: 2 buenas opciones en una buena respuesta sucinta. Otras respuestas anteriores presentan la gimnasia del sistema de tipos que son (teóricamente) válidas; pero para la mayoría de los casos, estas 2 opciones más simples son las opciones prácticas ideales.
Thomas W
1
Nota: En .NET, InvalidOperationException es promovido por Microsoft para que sea lo que usted llamó IllegalStateException.
miroxlav
1
No rechazo esta respuesta, pero en realidad no es una buena respuesta. Simplemente lanzando IllegalStateExceptiono InvalidOperationExceptionde la nada, un usuario nunca entenderá lo que hizo mal. Estas excepciones no deberían convertirse en una derivación para corregir la falla de diseño.
displayName
Notarás que el lanzamiento de excepciones es una opción posible y continúo elaborando mi opción preferida, haciendo que este tiempo de compilación sea seguro. He editado mi respuesta para enfatizar esto
Brian Agnew
14

Me voy a separar un poco de las otras respuestas y no estoy de acuerdo: es imposible responder esto sin saber en qué idioma estás trabajando. Si este es un plan que vale la pena o no, y el tipo de "advertencia" correcto para dar sus usuarios dependen completamente de los mecanismos que proporciona su idioma y las convenciones que otros desarrolladores de ese idioma tienden a seguir.

Si tiene un FooManagerHaskell, sería criminal permitir que sus usuarios construyan uno que no sea capaz de administrar Foos, porque el sistema de tipos lo hace tan fácil y esas son las convenciones que los desarrolladores de Haskell esperan.

Por otro lado, si está escribiendo C, sus compañeros de trabajo estarían completamente en su derecho de sacarlo y dispararle por definir tipos separados struct FooManagery struct UninitializedFooManagerque admitan diferentes operaciones, porque conduciría a un código innecesariamente complejo para muy poco beneficio .

Si está escribiendo Python, no hay ningún mecanismo en el lenguaje que le permita hacer esto.

Probablemente no esté escribiendo Haskell, Python o C, pero son ejemplos ilustrativos en términos de cuánto / qué poco trabajo se espera y pueda hacer el sistema de tipos.

Siga las expectativas razonables de un desarrollador en su idioma y resista el impulso de diseñar en exceso una solución que no tenga una implementación natural e idiomática (a menos que el error sea tan fácil de cometer y tan difícil de atrapar que valga la pena ir al extremo longitudes para hacerlo imposible). Si no tiene suficiente experiencia en su idioma para juzgar lo que es razonable, siga los consejos de alguien que lo sepa mejor que usted.

Patrick Collins
fuente
Estoy un poco confundido por "Si estás escribiendo Python, no hay ningún mecanismo en el lenguaje que te permita hacer esto". Python tiene constructores, y es bastante trivial crear métodos de fábrica. Entonces, ¿tal vez no entiendo lo que quieres decir con "esto"?
AL Flanagan
3
Con "esto", me refería a un error en tiempo de compilación, en lugar de uno en tiempo de ejecución.
Patrick Collins el
Saltando para mencionar Python 3.5, que es la versión actual, tiene tipos a través de un sistema de tipos conectable. Definitivamente es posible hacer que Python tenga un error antes de ejecutar el código solo porque se importó. Sin embargo, hasta 3.5 era completamente correcto.
Benjamin Gruenbaum
Oh ok Atrasado +1. Incluso confundido, esto fue muy perspicaz.
AL Flanagan
Me gusta tu estilo Patrick.
Gil Sand
4

Como parece que no desea enviar la verificación del código al cliente (pero parece estar bien hacerlo para el programador), puede usar las funciones de aserción , si están disponibles en su lenguaje de programación.

De esa manera, tiene las comprobaciones en el entorno de desarrollo (y cualquier prueba que un compañero de desarrollo llamaría fallará previsiblemente), pero no enviará el código al cliente ya que las afirmaciones (al menos en Java) solo se compilan de forma selectiva.

Entonces, una clase Java que use esto se vería así:

/** For some reason, you have to call init and connect before the manager works. */
public class FooManager {
   private int state = 0;

   public FooManager () {
   }

   public synchronized void init() {
      assert state==0 : "you called init twice.";
      // do something
      state = 1;
   }

   public synchronized void connect() {
      assert state==1 : "you called connect before init, or connect twice.";
      // do something
      state = 2;
   }

   public void someWork() {
      assert state==2 : "You did not properly init FooManager. You need to call init and connect first.";
      // do the actual work.
   }
}

Las afirmaciones son una gran herramienta para verificar el estado de tiempo de ejecución de su programa que necesita, pero en realidad no espere que alguien haga algo mal en un entorno real.

También son bastante delgados y no ocupan grandes porciones de if () throw ... sintaxis, no necesitan ser atrapados, etc.

Angelo Fuchs
fuente
3

Analizando este problema de manera más general que las respuestas actuales, que se han centrado principalmente en la inicialización. Considere un objeto que tendrá dos métodos, a()y b(). El requisito es que a()siempre se llama antes b(). Puede crear una comprobación en tiempo de compilación de que esto sucede devolviendo un nuevo objeto a()y moviéndose b()al nuevo objeto en lugar del original. Implementación de ejemplo:

class SomeClass
{
   private int valueRequiredByMethodB;

   public IReadyForB a (int v) { valueRequiredByMethodB = v; return new ReadyForB(this); }

   public interface IReadyForB { public void b (); }

   private class ReadyForB : IReadyForB
   {
      SomeClass owner;
      private ReadyForB (SomeClass owner) { this.owner = owner; }
      public void b () { Console.WriteLine (owner.valueRequiredByMethodB); }
   }
}

Ahora, es imposible llamar a b () sin primero llamar a (), ya que se implementa en una interfaz que está oculta hasta que se llama a (). Es cierto que esto es un gran esfuerzo, por lo que generalmente no usaría este enfoque, pero hay situaciones en las que puede ser beneficioso (especialmente si su clase será reutilizada en muchas situaciones por programadores que podrían no serlo). familiarizado con su implementación, o en código donde la confiabilidad es crítica). También tenga en cuenta que esta es una generalización del patrón de construcción, como lo sugieren muchas de las respuestas existentes. Funciona casi de la misma manera, la única diferencia real es dónde se almacenan los datos (en el objeto original en lugar del devuelto) y cuándo está destinado a ser utilizado (en cualquier momento versus solo durante la inicialización).

Jules
fuente
2

Cuando implemento una clase base que requiere información de inicialización adicional o enlaces a otros objetos antes de que sea útil, tiendo a hacer que esa clase base sea abstracta, luego defino varios métodos abstractos en esa clase que se usan en el flujo de la clase base (como abstract Collection<Information> get_extra_information(args);y abstract Collection<OtherObjects> get_other_objects(args);que por contrato de herencia debe ser implementado por la clase concreta, lo que obliga al usuario de esa clase base a suministrar todas las cosas requeridas por la clase base.

Por lo tanto, cuando implemento la clase base, sé inmediata y explícitamente lo que tengo que escribir para que la clase base se comporte correctamente, ya que solo necesito implementar los métodos abstractos y eso es todo.

EDITAR: para aclarar, esto es casi lo mismo que proporcionar argumentos al constructor de la clase base, pero las implementaciones del método abstracto permiten que la implementación procese los argumentos pasados ​​a las llamadas al método abstracto. O incluso en el caso de que no se utilicen argumentos, aún puede ser útil si el valor de retorno del método abstracto dependía de un estado que puede definir en el cuerpo del método, que no es posible al pasar la variable como argumento a El constructor. De acuerdo, aún puede pasar un argumento que tendrá un comportamiento dinámico basado en el mismo principio si prefiere usar la composición sobre la herencia.

klaar
fuente
2

La respuesta es: sí, no y, a veces. :-)

Algunos de los problemas que describe se resuelven fácilmente, al menos en muchos casos.

La verificación en tiempo de compilación es ciertamente preferible a la verificación en tiempo de ejecución, que es preferible a ninguna verificación en absoluto.

RE tiempo de ejecución:

Es al menos conceptualmente simple forzar que las funciones se llamen en un cierto orden, etc., con comprobaciones en tiempo de ejecución. Simplemente coloque banderas que digan qué función se ha ejecutado y deje que cada función comience con algo como "si no es pre-condición-1 o no es pre-condición-2, entonces arroje una excepción". Si los cheques se vuelven complejos, podría llevarlos a una función privada.

Puede hacer que algunas cosas sucedan automáticamente. Para tomar un ejemplo simple, los programadores a menudo hablan de "población perezosa" de un objeto. Cuando se crea la instancia, establece un indicador rellenado como falso o alguna referencia de objeto clave como nula. Luego, cuando se llama a una función que necesita los datos relevantes, verifica la bandera. Si es falso, completa los datos y establece el indicador como verdadero. Si es cierto, simplemente sigue asumiendo que los datos están allí. Puede hacer lo mismo con otras condiciones previas. Establezca una bandera en el constructor o como valor predeterminado. Luego, cuando llegue a un punto donde debería haberse llamado a alguna función de condición previa, si aún no se ha llamado, llámela. Por supuesto, esto solo funciona si tiene los datos para llamarlos en ese punto, pero en muchos casos puede incluir los datos necesarios en los parámetros de la función "

Tiempo de compilación RE:

Como han dicho otros, si necesita inicializar antes de poder usar un objeto, eso coloca la inicialización en el constructor. Este es un consejo bastante típico para OOP. Si es posible, debe hacer que el constructor cree una instancia válida y utilizable del objeto.

Veo una discusión sobre qué hacer si necesita pasar referencias al objeto antes de que se inicialice. No puedo pensar en un ejemplo real de eso fuera de mi cabeza, pero supongo que podría suceder. Se han sugerido soluciones, pero las soluciones que he visto aquí y en las que puedo pensar son desordenadas y complican el código. En algún momento tiene que preguntar: ¿Vale la pena hacer un código feo y difícil de entender para que podamos tener una verificación en tiempo de compilación en lugar de una verificación en tiempo de ejecución? ¿O estoy creando mucho trabajo para mí y para otros para un objetivo que es agradable pero no obligatorio?

Si dos funciones siempre deben ejecutarse juntas, la solución simple es convertirlas en una función. Al igual que si cada vez que agrega un anuncio a una página, también debe agregar un controlador de métricas para él, luego coloque el código para agregar el controlador de métricas dentro de la función "agregar anuncio".

Algunas cosas serían casi imposibles de verificar en tiempo de compilación. Como un requisito de que una determinada función no se puede llamar más de una vez. (He escrito muchas funciones como esa. Recientemente escribí una función que busca archivos en un directorio mágico, procesa los que encuentra y luego los elimina. Por supuesto, una vez que se elimina el archivo, no es posible volver a ejecutarlo. Etc.) No conozco ningún idioma que tenga una función que le permita evitar que se llame dos veces a una función en la misma instancia en tiempo de compilación. No sé cómo el compilador podría darse cuenta definitivamente de que eso estaba sucediendo. Todo lo que puede hacer es poner controles de tiempo de ejecución. Esa comprobación de tiempo de ejecución en particular es obvia, ¿no? "if already-been-here = true, entonces arroja la excepción, de lo contrario establece set-been-here = true"

RE imposible de hacer cumplir

No es raro que una clase requiera algún tipo de limpieza cuando haya terminado: cerrar archivos o conexiones, liberar memoria, escribir resultados finales en la base de datos, etc. No hay una manera fácil de forzar que eso suceda en el momento de la compilación o tiempo de ejecución. Creo que la mayoría de los lenguajes OOP tienen algún tipo de provisión para una función de "finalizador" que se llama cuando se recolecta basura una instancia, pero creo que la mayoría también dice que no pueden garantizar que esto se ejecute. Los lenguajes OOP a menudo incluyen una función de "disposición" con algún tipo de disposición para establecer un alcance sobre el uso de una instancia, una cláusula de "uso" o "con", y cuando el programa sale de ese alcance, se ejecuta la disposición. Pero esto requiere que el programador use el especificador de alcance apropiado. No obliga al programador a hacerlo bien,

En los casos en que es imposible obligar al programador a usar la clase correctamente, trato de hacerlo para que el programa explote si lo hace mal, en lugar de dar resultados incorrectos. Ejemplo simple: he visto muchos programas que inicializan una gran cantidad de datos a valores ficticios, para que el usuario nunca obtenga una excepción de puntero nulo, incluso si no llama a las funciones para llenar los datos correctamente. Y siempre me pregunto por qué. Estás haciendo todo lo posible para que los errores sean más difíciles de encontrar. Si, cuando un programador no puede llamar a la función "cargar datos" y luego intenta usar los datos alegremente, obtiene una excepción de puntero nulo, la excepción le mostrará rápidamente dónde está el problema. Pero si lo oculta al dejar los datos en blanco o cero en tales casos, el programa puede ejecutarse hasta su finalización pero producir resultados inexactos. En algunos casos, el programador puede no darse cuenta de que había un problema. Si se da cuenta, puede que tenga que seguir un largo camino para encontrar dónde se equivocó. Es mejor fallar temprano que tratar de seguir valientemente.

En general, claro, siempre es bueno cuando puedes hacer un uso incorrecto de un objeto simplemente imposible. Es bueno si las funciones están estructuradas de tal manera que el programador simplemente no puede hacer llamadas no válidas, o si las llamadas no válidas generan errores en tiempo de compilación.

Pero también hay un punto en el que debe decir: El programador debe leer la documentación sobre la función.

Arrendajo
fuente
1
Con respecto a la limpieza forzada, hay un patrón que se puede usar donde un recurso solo se puede asignar llamando a un método en particular (por ejemplo, en un lenguaje OO típico, podría tener un constructor privado). Este método asigna el recurso, llama a una función (o un método de una interfaz) que se le pasa con una referencia al recurso, y luego destruye el recurso una vez que esta función regresa. Además de la terminación abrupta del subproceso, este patrón garantiza que el recurso siempre se destruya. Es un enfoque común en lenguajes funcionales, pero también he visto que se usa en sistemas OO con buenos resultados.
Jules
@Jules aka "disposers"
Benjamin Gruenbaum
2

Sí, tus instintos son acertados. Hace muchos años escribí artículos en revistas (algo así como este lugar, pero en un árbol muerto y lo lancé una vez al mes) sobre Debugging , Abugging y Antibugging .

Si puede hacer que el compilador detecte el mal uso, esa es la mejor manera. Las funciones de idioma disponibles dependen del idioma y no lo especificó. De todos modos, se detallarían técnicas específicas para preguntas individuales en este sitio.

Pero tener una prueba para detectar el uso en tiempo de compilación en lugar del tiempo de ejecución es realmente el mismo tipo de prueba: aún debe saber llamar primero a las funciones adecuadas y usarlas de la manera correcta.

Diseñar el componente para evitar que incluso ser un problema en primer lugar sea mucho más sutil, y me gusta que el Buffer sea como el ejemplo de Element . La Parte 4 (¡publicada hace veinte años! ¡Guau!) Contiene un ejemplo de discusión que es bastante similar a su caso: esta clase debe usarse con cuidado, ya que es posible que no se invoque buffer () después de comenzar a usar read (). Por el contrario, solo debe usarse una vez, inmediatamente después de la construcción. Hacer que la clase administre el búfer de forma automática e invisible para la persona que llama evitaría el problema conceptualmente.

Usted pregunta, si se pueden realizar pruebas en tiempo de ejecución para garantizar un uso adecuado, ¿debería hacerlo?

Yo diría que . Le ahorrará a su equipo una gran cantidad de trabajo de depuración algún día, cuando se mantenga el código y se perturbe el uso específico. La prueba se puede configurar como un conjunto formal de restricciones e invariantes que se pueden probar. Eso no significa que la sobrecarga de espacio adicional para rastrear el estado y el trabajo para realizar la verificación siempre se deja en cada llamada. Está en la clase, por lo que podría llamarse, y el código documenta las restricciones y suposiciones reales.

Quizás la comprobación solo se realiza en una compilación de depuración.

Quizás la verificación es compleja (piense en el heapcheck, por ejemplo), pero está presente y se puede hacer de vez en cuando, o se agregan llamadas aquí y allá cuando se está trabajando en el código y surge algún problema, o para hacer pruebas específicas para asegúrese de que no haya tal problema en un programa de prueba de unidad o prueba de integración que se vincule a la misma clase.

Puede decidir que la sobrecarga es trivial después de todo. Si la clase archiva E / S, y un byte de estado adicional y una prueba para tal no es nada. Las clases comunes de iostream verifican que la secuencia esté en mal estado.

Tus instintos son buenos. ¡Seguid así!

JDługosz
fuente
0

El principio de Ocultación de información (Encapsulación) es que las entidades fuera de la clase no deben saber más de lo que se requiere para usar esta clase correctamente.

Parece que su caso está tratando de hacer que un objeto de una clase se ejecute correctamente sin decirle a los usuarios externos lo suficiente sobre la clase. Has ocultado más información de la que deberías tener.

  • Pide explícitamente esa información requerida en el constructor;
  • O mantenga los constructores intactos y modifique los métodos para que, para llamarlos, tenga que proporcionar los datos que faltan.

Palabras de la sabiduría:

* De cualquier manera, debe rediseñar la clase y / o el constructor y / o los métodos.

* Al no diseñar adecuadamente y privar a la clase de la información correcta, está arriesgando su clase para romper no en uno, sino en múltiples lugares.

* Si ha escrito mal las excepciones y los mensajes de error, su clase estará regalando aún más sobre sí mismo.

* Finalmente, si se supone que el extraño sabe que tiene que inicializar a, byc y luego llamar a d (), e (), f (int), entonces tiene una fuga en su abstracción.

nombre para mostrar
fuente
0

Como ha especificado que está usando C #, una solución viable para su situación es aprovechar los analizadores de código de Roslyn . Esto le permitirá detectar violaciones de inmediato e incluso le permitirá sugerir correcciones de código .

Una forma de implementarlo sería decorar los métodos acoplados temporalmente con un atributo que especifique el orden en que los métodos deben llamarse 1 . Cuando el analizador encuentra estos atributos en una clase, valida que los métodos se llamen en orden. Esto haría que su clase se parezca a lo siguiente:

public abstract class Foo
{
   [TemporallyCoupled(1)]
   public abstract void Init();

   [TemporallyCoupled(2)]
   public abstract void DoBar();

   [TemporallyCoupled(3)]
   public abstract void Close();
}

1: Nunca he escrito un Roslyn Code Analyzer, por lo que esta podría no ser la mejor implementación. Sin embargo, la idea de usar Roslyn Code Analyzer para verificar que su API se está utilizando correctamente es 100% sólida.

Erik
fuente
-1

La forma en que resolví este problema antes fue con un constructor privado y un MakeFooManager()método estático dentro de la clase. Ejemplo:

public class FooManager
{
     public string Foo { get; private set; }
     public string Bar { get; private set; }

     private FooManager(string foo, string bar)
     {
         Foo = foo;
         Bar = bar;
     }

     public static FooManager MakeFooManager(string foo, string bar)
     {
         // Can do other checks here too.
         if(foo == null || bar == null)
         {
             return null;
         }
         else
         {
             return new FooManager(foo, bar);
         }
     }
}

Dado que se implementa un constructor, no hay forma de que alguien cree una instancia FooManagersin pasar por ella MakeFooManager().

WillB3
fuente
1
esto parece simplemente repetir el punto hecho y explicado en una respuesta anterior que fue publicada varias horas antes
mosquito
1
¿Tiene esto alguna ventaja sobre la simple comprobación nula en el ctor? parece que acabas de hacer un método que no hace nada más que envolver otro método. ¿por qué?
sara
Además, ¿por qué son las variables miembro foo y bar?
Patrick M
-1

Hay varios enfoques:

  1. Haga que la clase sea fácil de usar correctamente y difícil de usar incorrectamente. Algunas de las otras respuestas se centran exclusivamente en este punto, por lo que simplemente mencionaré RAII y el patrón de construcción .

  2. Tenga en cuenta cómo usar los comentarios. Si la clase se explica por sí sola, no escriba comentarios. * De esa manera, es más probable que las personas lean sus comentarios cuando la clase realmente los necesita. Y si tiene uno de estos casos raros donde una clase es difícil de usar correctamente y necesita comentarios, incluya ejemplos.

  3. Use afirmaciones en sus compilaciones de depuración.

  4. Lanza excepciones si el uso de la clase no es válido.

* Estos son los tipos de comentarios que no debes escribir:

// gets Foo
GetFoo();
Peter
fuente