¿Qué código debe incluirse en una clase abstracta?

10

Últimamente me preocupa el uso de clases abstractas.

A veces se crea una clase abstracta por adelantado y funciona como una plantilla de cómo funcionarían las clases derivadas. Eso significa, más o menos, que proporcionan una funcionalidad de alto nivel, pero omite ciertos detalles para ser implementados por las clases derivadas. La clase abstracta define la necesidad de estos detalles al implementar algunos métodos abstractos. En tales casos, una clase abstracta funciona como un plano, una descripción de alto nivel de la funcionalidad o como quiera llamarlo. No se puede usar por sí solo, pero debe estar especializado para definir los detalles que se han dejado fuera de la implementación de alto nivel.

En otras ocasiones, sucede que la clase abstracta se crea después de la creación de algunas clases "derivadas" (dado que la clase principal / abstracta no está allí, todavía no se han derivado, pero ya sabes a qué me refiero). En estos casos, la clase abstracta generalmente se usa como un lugar donde puede colocar cualquier tipo de código común que contengan las clases derivadas actuales.

Habiendo hecho las observaciones anteriores, me pregunto cuál de estos dos casos debería ser la regla. ¿Debería extenderse algún tipo de detalle a la clase abstracta solo porque actualmente son comunes en todas las clases derivadas? ¿Debería existir un código común que no sea parte de una funcionalidad de alto nivel?

¿Debería existir un código que no tenga significado para la clase abstracta en sí solo porque resulta ser común para las clases derivadas?

Permítanme dar un ejemplo: la clase abstracta A tiene un método a () y un método abstracto aq (). El método aq (), en ambas clases derivadas AB y AC, utiliza un método b (). ¿Debería b () moverse a A? En caso afirmativo, en caso de que alguien mire solo a A (supongamos que AB y AC no están allí), ¡la existencia de b () no tendría mucho sentido! ¿Esto es malo? ¿Debería alguien poder echar un vistazo a una clase abstracta y comprender lo que está sucediendo sin visitar las clases derivadas?

Para ser honesto, al momento de preguntar esto, tiendo a creer que escribir una clase abstracta que tenga sentido sin tener que buscar en las clases derivadas es una cuestión de código limpio y arquitectura limpia. Ι Realmente no me gusta la idea de una clase abstracta que actúe como un volcado para cualquier tipo de código que es común en todas las clases derivadas.

¿Qué piensas / practicas?

Alexandros Gougousis
fuente
77
¿Por qué debe haber una "regla"?
Robert Harvey
1
Writing an abstract class that makes sense without having to look in the derived classes is a matter of clean code and clean architecture.-- ¿Por qué? ¿No es posible que, en el curso del diseño y desarrollo de una aplicación, descubra que varias clases tienen una funcionalidad común que naturalmente se puede refactorizar en una clase abstracta? ¿Debo ser lo suficientemente clarividente como para anticipar esto antes de escribir cualquier código para las clases derivadas? Si no soy tan astuto, ¿tengo prohibido realizar una refactorización de este tipo? ¿Debo tirar mi código y comenzar de nuevo?
Robert Harvey
Lo siento, si me entendieron mal Estaba tratando de decir lo que siento (por el momento) como una mejor práctica y no implicando que debería haber una regla absoluta. Además, no estoy insinuando que cualquier código que pertenezca a la clase abstracta debe escribirse solo por adelantado. Estaba describiendo cómo, en la práctica, una clase abstracta termina con un código de alto nivel (que actúa como una plantilla para los derivados), así como un código de bajo nivel (que no puedes entender si es usabilidad si no miras las clases derivadas)
Alexandros Gougousis
@RobertHarvey para una clase de base pública, se le prohibiría mirar las clases derivadas. Para las clases internas, no hay diferencia.
Frank Hileman

Respuestas:

4

Permítanme dar un ejemplo: la clase abstracta A tiene un método a () y un método abstracto aq (). El método aq (), en ambas clases derivadas AB y AC, utiliza un método b (). ¿Debería b () moverse a A? En caso afirmativo, en caso de que alguien mire solo a A (supongamos que AB y AC no están allí), ¡la existencia de b () no tendría mucho sentido! ¿Esto es malo? ¿Debería alguien poder echar un vistazo a una clase abstracta y comprender lo que está sucediendo sin visitar las clases derivadas?

Lo que pregunta es dónde colocar b(), y, en otro sentido, una pregunta es si Aes la mejor opción como la súper clase inmediata para ABy AC.

Parece que hay tres opciones:

  1. dejar b()en ambos AByAC
  2. crea una clase intermedia ABAC-Parentque hereda Ay que introduce b(), y luego se usa como la superclase inmediata para AByAC
  3. puso b()en A(sin saber si otra clase futuro ADva a querer b()o no)

  1. sufre de no estar SECO .
  2. sufre de YAGNI .
  3. entonces, eso deja a este.

Hasta que se presente otra clase ADque no quiera b(), (3) parece ser la elección correcta.

En el momento de un ADregalo, podemos refactorizar el enfoque en (2). ¡Después de todo, es software!

Erik Eidt
fuente
77
Hay una cuarta opción. No pongas b()en ninguna clase. Conviértalo en una función libre que tome todos sus datos como argumentos y tenga ambos ABy ACllámelo. Esto significa que no necesita moverlo o crear más clases cuando agrega AD.
user1118321
1
@ user1118321, excelente, buen pensamiento fuera de la caja. Tiene particular sentido si b()no necesita un estado de instancia de larga vida.
Erik Eidt
Lo que me preocupa en (3) es que la clase abstracta ya no describe un comportamiento autónomo. Es un código y no puedo entender el código de la clase abstracta sin ir y venir entre esta y todas las clases derivadas.
Alexandros Gougousis
¿Puedes hacer una base / default acque llame en blugar de acser abstracto?
Erik Eidt
3
Para mí, la pregunta más importante es, ¿POR QUÉ tanto AB como AC usan el mismo método b ()? Si es solo una coincidencia, dejaría dos implementaciones similares en AB y AC. Pero probablemente se deba a que existe una abstracción común para AB y AC. Para mí, DRY no es tanto un valor en sí mismo, sino una pista de que probablemente te perdiste alguna abstracción útil.
Ralf Kleberhoff
2

Una clase abstracta no está destinada a ser el vertedero de varias funciones o datos que se agregan a la clase abstracta porque es conveniente.

La regla general que parece proporcionar el enfoque orientado a objetos más confiable y extensible es " Prefiero la composición sobre la herencia ". Una clase abstracta probablemente se considere mejor como una especificación de interfaz que no contiene ningún código.

Si tiene algún método que es un tipo de método de biblioteca que acompaña a la clase abstracta, un método que es el medio más probable de expresar alguna funcionalidad o comportamiento que las clases derivadas de una clase abstracta normalmente necesitan, entonces tiene sentido crear un clase entre la clase abstracta y otras clases donde este método está disponible. Esta nueva clase proporciona una instanciación particular de la clase abstracta que define una alternativa o ruta de implementación particular al proporcionar este método.

La idea de una clase abstracta es tener un modelo abstracto de lo que se supone que deben proporcionar las clases derivadas que realmente implementan la clase abstracta en cuanto a servicio o comportamiento. La herencia es fácil de usar y muchas veces las clases más útiles se componen de varias clases que usan un patrón de mezcla .

Sin embargo, siempre hay preguntas de cambio, qué va a cambiar y cómo va a cambiar y por qué va a cambiar.

La herencia puede conducir a cuerpos de fuente frágiles y frágiles (vea también Herencia: ¡simplemente deje de usarla ya! ).

El problema de la clase base frágil es un problema arquitectónico fundamental de los sistemas de programación orientados a objetos donde las clases base (superclases) se consideran "frágiles" porque las modificaciones aparentemente seguras de una clase base, cuando las clases derivadas las heredan, pueden causar un mal funcionamiento de las clases derivadas. . El programador no puede determinar si un cambio de clase base es seguro simplemente examinando de forma aislada los métodos de la clase base.

Richard Chambers
fuente
¡Estoy seguro de que cualquier concepto de OOP puede generar problemas si se usa incorrectamente, se usa en exceso o se abusa de él! Además, suponer que b () es un método de biblioteca (por ejemplo, una función pura sin otras dependencias) reduce el alcance de mi pregunta. Evitemos esto.
Alexandros Gougousis
@AlexandrosGougousis No estoy asumiendo que b()sea ​​una función pura. Podría ser un functoid o una plantilla u otra cosa. Estoy usando la frase "método de biblioteca" como un componente, ya sea una función pura, un objeto COM o lo que sea que se use para la funcionalidad que proporciona a la solución.
Richard Chambers el
Lo siento, si no estaba claro! Mencioné la función pura como uno de muchos ejemplos (usted ha dado más).
Alexandros Gougousis
1

Su pregunta sugiere una o una aproximación a las clases abstractas. Pero creo que debería considerarlos como una herramienta más en su caja de herramientas. Y luego la pregunta es: ¿para qué trabajos / problemas son las clases abstractas la herramienta adecuada?

Un excelente caso de uso es implementar el patrón de método de plantilla . Pones toda la lógica invariante en la clase abstracta y la lógica variante en sus subclases. Tenga en cuenta que la lógica compartida en sí misma es incompleta y no funcional. La mayoría de las veces se trata de implementar un algoritmo, donde varios pasos son siempre iguales, pero al menos uno varía. Ponga este paso como un método abstracto que se llama desde una de las funciones dentro de la clase abstracta.

A veces se crea una clase abstracta por adelantado y funciona como una plantilla de cómo funcionarían las clases derivadas. Eso significa, más o menos, que proporcionan una funcionalidad de alto nivel, pero omite ciertos detalles para ser implementados por las clases derivadas. La clase abstracta define la necesidad de estos detalles al implementar algunos métodos abstractos. En tales casos, una clase abstracta funciona como un plano, una descripción de alto nivel de la funcionalidad o como quiera llamarlo. No se puede usar por sí solo, pero debe estar especializado para definir los detalles que se han dejado fuera de la implementación de alto nivel.

Creo que su primer ejemplo es esencialmente una descripción del patrón del método de plantilla (corríjame si me equivoco), por lo que consideraría esto como un caso de uso perfectamente válido de clases abstractas.

En otras ocasiones, sucede que la clase abstracta se crea después de la creación de algunas clases "derivadas" (dado que la clase principal / abstracta no está allí, todavía no se han derivado, pero ya sabes a qué me refiero). En estos casos, la clase abstracta generalmente se usa como un lugar donde puede colocar cualquier tipo de código común que contengan las clases derivadas actuales.

Para su segundo ejemplo, creo que el uso de clases abstractas no es la opción óptima, porque existen métodos superiores para lidiar con la lógica compartida y el código duplicado. Digamos que usted tiene clase abstracta A, las clases derivadas By Cy ambas clases derivadas compartir algo de lógica en forma de método s(). Para decidir el enfoque correcto para deshacerse de la duplicación, es importante saber si el método s()es parte de la interfaz pública o no.

Si no es así (como en su propio ejemplo concreto con el método b()), el caso es bastante simple. Simplemente cree una clase separada, extrayendo también el contexto que se necesita para realizar la operación. Este es un ejemplo clásico de composición sobre herencia . Si se necesita poco o ningún contexto, una función auxiliar simple podría ser suficiente como se sugiere en algunos comentarios.

Si s()es parte de la interfaz pública, se vuelve un poco más complicado. Bajo la suposición de que s()no tiene nada que ver By con lo que Cestá relacionado A, no debes ponerlo s()dentro A. Entonces, ¿dónde ponerlo? Yo argumentaría por declarar una interfaz separada I, que define s(). Por otra parte, debe crear una clase separada que contenga la lógica para implementar s()y ambas, By Cdependa de ello.

Por último, aquí hay un enlace a una excelente respuesta de una pregunta interesante sobre SO que podría ayudarlo a decidir cuándo ir a una interfaz y cuándo para una clase abstracta:

Una buena clase abstracta reducirá la cantidad de código que debe reescribirse porque su funcionalidad o estado pueden compartirse.

larsbe
fuente
Estoy de acuerdo en que s () ser parte de la interfaz pública hace las cosas mucho más difíciles. En general, evitaría definir métodos públicos que llamen a otros métodos públicos en la misma clase.
Alexandros Gougousis
1

Me parece que te estás perdiendo el punto de orientación del objeto, tanto lógica como técnicamente. Describe dos escenarios: agrupar el comportamiento común de los tipos en una clase base y el polimorfismo. Ambas son aplicaciones legítimas de una clase abstracta. Pero si debe hacer que la clase sea abstracta o no depende de su modelo analítico, no de las posibilidades técnicas.

Debes reconocer un tipo que no tiene encarnaciones en el mundo real, pero que sienta las bases para tipos específicos que sí existen. Ejemplo: un animal. No hay tal cosa como un animal. Siempre es un perro o un gato o lo que sea, pero no hay un animal real. Sin embargo, Animal los enmarca a todos.

Entonces hablas de niveles. Los niveles no son parte del trato cuando se trata de herencia. Tampoco es reconocer datos comunes o comportamientos comunes, ese es un enfoque técnico que probablemente no ayudará. Debe reconocer un estereotipo y luego insertar la clase base. Si no existe este estereotipo, puede ser mejor con un par de interfaces implementadas por múltiples clases.

El nombre de su clase abstracta junto con sus miembros debería tener sentido. Debe ser independiente de cualquier clase derivada, tanto técnica como lógicamente. Like Animal podría tener un método abstracto Eat (que sería polimórfico) y propiedades abstractas booleanas IsPet e IsLifestock, que tiene sentido sin saber acerca de gatos, perros o cerdos. Cualquier dependencia (técnica o lógica) debe ir solo en un sentido: de descendiente a base. Las clases base tampoco deberían tener conocimiento de las clases descendentes.

Martin Maat
fuente
El estereotipo que mencionas es más o menos lo mismo que quiero decir cuando hablo de la funcionalidad de nivel superior o cuando alguien más habla de conceptos generalizados que se describen por clase más alta en la jerarquía (frente a conceptos más especializados descritos por clases más bajas en el jerarquía).
Alexandros Gougousis
"Las clases base tampoco deberían tener conocimiento de clases descendentes". Estoy totalmente de acuerdo con esto. Una clase principal (abstracta o no) debe contener una lógica empresarial independiente que no necesite inspección de la implementación de la clase secundaria para que tenga sentido.
Alexandros Gougousis
Hay otra cosa sobre una clase base que conoce sus subtipos. Cada vez que se desarrolla un nuevo subtipo, la clase base necesita ser modificada, lo que está al revés y viola el principio abierto-cerrado. Recientemente encontré esto en el código existente. Una clase base tenía un método estático que creaba instancias de subtipos basados ​​en la configuración de la aplicación. La intención era un patrón de fábrica. Hacerlo de esta manera también viola SRP. Con algunos puntos SOLIDOS solía pensar "duh, eso es obvio" o "el lenguaje se encargará automáticamente de eso", pero descubrí que las personas son más creativas de lo que podría imaginar.
Martin Maat
0

Primer caso , de su pregunta:

En tales casos, una clase abstracta funciona como un plano, una descripción de alto nivel de la funcionalidad o como quiera llamarlo.

Segundo caso:

En otras ocasiones ... la clase abstracta generalmente se usa como un lugar donde puede colocar cualquier tipo de código común que contengan las clases derivadas actuales.

Entonces, tu pregunta :

Me pregunto cuál de estos dos casos debería ser la regla. ... ¿Debería existir un código que no tenga significado para la clase abstracta en sí solo porque resulta ser común para las clases derivadas?

En mi opinión, tiene dos escenarios diferentes, por lo tanto, no puede dibujar una sola regla para decidir qué diseño debe aplicarse.

Para el primer caso, tenemos cosas como el patrón del Método de plantilla ya mencionado en otras respuestas.

Para el segundo caso, dio el ejemplo del método abstracto de clase A que recibe ab (); En mi opinión, el método b () solo debe moverse si tiene sentido para TODAS las otras clases derivadas. En otras palabras, si lo está moviendo porque se usa en dos lugares, probablemente esta no sea una buena opción, porque mañana podría haber una nueva clase Derivada concreta en la que b () no tiene ningún sentido. Además, como dijiste, si miras A clase por separado yb () tampoco tiene sentido en ese contexto, entonces probablemente sea una pista de que esta no es una buena opción de diseño también.

Emerson Cardoso
fuente
-1

¡He tenido exactamente las mismas preguntas hace algún tiempo!

A lo que llegué es que cada vez que me encuentro con este problema, encuentro que hay algún concepto oculto que no he encontrado. Y este concepto más probablemente debe expresarse con algún objeto de valor . Tienes un ejemplo muy abstracto, así que me temo que no puedo demostrar lo que quiero decir con tu código. Pero aquí hay un caso de mi propia práctica. Tenía dos clases que representaban una forma de enviar una solicitud a un recurso externo y analizar la respuesta. Hubo similitudes en cómo se formaron las solicitudes y cómo se analizaron las respuestas. Así es como se veía mi jerarquía:

abstract class AbstractProtocol
{
    /**
     * @return array Registration params to send
     */
    abstract protected function assembleRegistrationPart();

    /**
     * @return array Payment params to send
     */
    abstract protected function assemblePaymentPart();

    protected function doSend(array $data)
    {
        return
            (new HttpClient(
                [
                    'timeout' => 60,
                    'encoding' => 'utf-8',
                    'language' => 'en',
                ]
            ))
                ->send($data);
    }

    protected function log(array $data)
    {
        $header = 'Here is a request to external system!';
        $body = implode(', ', $this->maskData($data));
        Logger::log($header . '. \n ' . $body);
    }
}

class ClassicProtocol extends AbstractProtocol
{
    public function send()
    {
        $registration = $this->assembleRegistrationPart();
        $payment = $this->assemblePaymentPart();
        $specificParams = $this->assembleClassicSpecificPart();

        $dataToSend =
            array_merge(
                $registration, $payment, $specificParams
            );

        $this->log($dataToSend);

        $this->doSend($dataToSend);
    }

    protected function assembleRegistrationPart()
    {
        return ['hello' => 'there'];
    }

    protected function assemblePaymentPart()
    {
        return ['pay' => 'yes'];
    }
}

Tal código indica que simplemente uso incorrectamente la herencia. Así es como se podría refactorizar:

class ClassicProtocol
{
    private $request;
    private $logger;

    public function __construct(Request $request, Logger $logger, Client $client)
    {
        $this->request = $request;
        $this->client = $client;
        $this->logger = $logger;
    }

    public function send()
    {
        $this->logger->log($this->request->getData());
        $this->client->send($this->request->getData());
    }
}

$protocol =
    new ClassicProtocol(
        new PaymentRequest(
            new RegistrationData(),
            new PaymentData(),
            new ClassicSpecificData()
        ),
        new ClassicLogger(),
        new ClassicClient()
    );

class RegistrationData
{
    public function getData()
    {
        return ['hello' => 'there'];
    }
}

class PaymentData
{
    public function getData()
    {
        return ['pay' => 'yes'];
    }
}

class ClassicLogger
{
    public function log(array $data)
    {
        $header = 'Here is a request to external system!';
        $body = implode(', ', $this->maskData($data));
        Logger::log($header . '. \n ' . $body);
    }
}
class ClassicClient
{
    private $properties;

    public function __construct()
    {
        $this->properties =
            [
                'timeout' => 60,
                'encoding' => 'utf-8',
                'language' => 'en',
            ];
    }
}

Desde entonces trato la herencia con mucho cuidado porque me lastimé muchas veces.

Desde entonces llegué a otra conclusión sobre la herencia. Estoy totalmente en contra de la herencia basada en la estructura interna . Rompe la encapsulación, es frágil, es procesal después de todo. Y cuando descompongo mi dominio de la manera correcta , la herencia simplemente no ocurre muy a menudo.

Vadim Samokhin
fuente
@Downvoter, por favor hazme un favor y comenta lo que está mal con esta respuesta.
Vadim Samokhin el