Hablemos del principio de sustitución de Liskov
Todo buen desarrollador que trabaja a diario con orientación a objetos conoce y aplica hoy los principios SOLID. Sin embargo, tanto en lo personal como en diferentes entrevistas que he tomado, noté que uno de estos principios es poco recordado, o cuando se lo recuerda, es poco comprendido. Estoy hablando del Principio de Sustitución de Liskov.
Sin embargo, antes de continuar me gustaría recitar unas palabras sobre su creadora, la Doctora Barbara Liskov. Su nombre original es Barbara Jane Huberman (Liskov es su apellido de casada), es una científica noreamericana que se destacó por sus estudios y desarrollos en la Ingeniería de Software. Sólo por nombrar algunos de sus logros, fue una de las primeras mujeres en obtener un doctorado en Ciencias de la Computación en Estados Unidos, en 2004 se hizo merecedora de la medalla John Von Neumann y en 2008 fue galardonada con el Premio Turing por sus aportes en la materia. Como vemos, toda una eminencia en el campo de las Ciencias Informáticas.
El propósito de las palabras anteriores es el siguiente: todos los años de estudio e investigación de la Doctora Liskov nos han dejado, entre otros aportes, un simple pero poderosísimo postulado:
Si S es un subtipo de T, entonces los objetos de tipo T pueden ser reemplazados con objetos de tipo S, sin romper el programa.
En principio no dice mucho ¿verdad? ¡Desde luego que una clase padre debería poder sustituirse por su clase hija! Y desde luego que nadie quiere romper el programa… Bueno, a veces las cosas simples tienen implicaciones mucho más complejas de lo que creemos. Por citar un ejemplo, todos conocemos la formula de equivalencia masa-energía (E=mc²), sin embargo son muy pocos los que realmente comprenden sus implicaciones (yo soy uno de los que no).
Es ahora cuando apelando al psicoanálisis lacaniano te pregunto querido lector: ¿realmente comprendes el principio?, y yendo más allá: ¿realmente aplicas este principio en tu código? Hagámonos estas preguntas juntos. Para ello, voy a citar un caso típico que he visto varias veces, incluso en sistemas de muy buena calidad. El diseñador del sistema ha decidido implementar una arquitectura de software corporativa (como Ports and Adapters o CQRS), para lo cual crea un pequeño conjunto de reglas:
- Existirán objetos de solicitud que contendrán la información de entrada;
- Existirán manejadores (handlers) que recibirán este objeto de solicitud, ejecutarán el caso de uso tal cual se ha definido en el requerimiento, y devolverán un resultado de la operación (no necesariamente datos).
Luego, diseña una prueba de concepto muy simple y la infraestructura para que ésta funcione:
abstract class Request {
function __construct(protected array $input) {
$this->mapInput($input);
}
abstract function mapInput(array $input);
}
interface Handler {
function handle(Request $request): string;
}
class RequestDispatcher {
function dispatch(Request $request, Handler $handler) {
return $handler->handle($request);
}
}
//index.php:
echo RequestDispatcher::dispatch (
new RegisterUserRequest (['username'=>'ncastro', 'password'=>'123']),
new RegisterUserHandler (new UserRepository())
);
class RegisterUserRequest extends Request {
public $username;
public $password;
function mapInput(array $input) {
$this->username = $input['username'];
$this->password = encrypt($input['password']);
}
}
class RegisterUserHandler implements Handler {
function __construct (private UserRepository $users) {}
function handle (Request $request): string {
$this-users->persist('user', $request);
return 'ok';
}
}
Luego de esta prueba, el diseñador se siente conforme y procede con la implementación de un segundo caso de uso, y luego con un tercero, y poco a poco el sistema va cobrando vida. Pasan unos cuantos meses, el sistema ya está en producción y se continúan agregando nuevas funcionalidades. Un día se acerca un ingeniero con una pregunta en principio simple: disculpe, ¿qué me impide enviar al manejador un objeto de solitud diferente al que espera? La respuesta del diseñador seguramente será tajante: NO HAGAS ESO. Y aquí es cuando nos preguntamos: ¿es realmente improcedente la pregunta del ingeniero?
No, no lo es. Y sí, hay un problema en el diseño. Cuando la Dra. Liskov nos dice que una clase padre debería poder sustituirse por su clase hija sin problemas, no está describiendo un fenómeno aleatorio, sino que está expondiendo una restricción muy fuerte sin la cual nuestro sistema será vulnerable: si aplicas una abstracción, sé consciente de que el contrato definido más arriba debe seguir cumpliéndose en todas las implementaciones, ya que nada impide que alguien envíe cualquier otra clase asociada a ese contrato.
Formalmente, el LSP nos propone una serie de reglas que pueden consultar en este excelente artículo, pero que a resumidas cuentas nos indican que un subtipo no debería modificar el comportamiento definido en el supertipo, con algunas salvedades.
Por si aún no has encontrado el problema en el código:
abstract function mapInput(array $input) // es incorrecto asumir que todas las solicitudes recibirán un vector como entrada
function handle(Request $request): string // es incorrecto asumir que todos los manejadores recibirán cualquier tipo de solicitud
Este error es muy común y nace de un supuesto incorrecto que muchos ingenieros hacemos: si tengo que garantizar un tipo de datos de entrada, lo fuerzo mediante una abstracción. La idea de la orientación a objetos no es forzar cosas, sino definir estructuras eficientes e interoperables. Es por ello que con el pasar del tiempo toma más fuerza y sentido la noción de composición sobre herencia.
En el ejemplo de código no era realmente importante forzar la implementación del método ni el tipo de datos, ya que si nos detenemos a pensar, la idea de que un caso de uso tiene algo en común con otro es completamente errónea por definición. Cada caso de uso es único, así como lo es en la vida real. Por ello, reformulemos el código:
class RequestDispatcher {
function dispatch ($request, $handler) {
if(!method_exists('handle', $handler)) {
throw new InvalidHandlerException(get_class_name($handler));
}
return $handler->handle($request);
}
}
//index.php:
echo RequestDispatcher::dispatch (
RegisterUserRequest::fromArray(['username'=>'ncastro', 'password'=>'123']),
new RegisterUserHandler(new UserRepository())
);
class RegisterUserRequest {
public static function fromArray (array $input) {
return new self(input['username'], input['password']);
}
private function __construct (
private string $username,
private string $password
) {}
//getters
}
class RegisterUserHandler {
function __construct (private UserRepository $users) {}
function handle (RegisterUserRequest $request): string {
$this-users->persist('user', $request);
return 'ok';
}
}
Adiós abstracciones. Valga además aclarar que en un caso típico no tendríamos un RequestDispatcher
, sino una serie de actions/commands (controladores con responsabilidad única) por encima de nuestros manejadores, los cuales estarían gestionados por un framework y se encargarían de llamar a cada caso de uso de forma explícita. Esto facilitaría la intercambiabilidad de interfaces externas sin necesidad de tocar los casos de uso (por ejemplo, usar un mismo caso de uso para una interfaz web como para un comando de consola).
Concluyendo
El LSP es un principio clave en nuestra disciplina, ya que su violación denota un claro problema de diseño de nuestra solución. Esto no implica que sea el más imortante de los principios SOLID, sino que es tan importante como el resto y por ello, debemos comprenderlo y saber aplicarlo.
Para quienes quieran continuar leyendo sobre este gran principio, les recomiendo que busquen subtyping.
Y un agregado, espero que después de leer este artículo, cuando te pregunten qué significa la “L” en “SOLID”, no respondas simplemente “la L es por Liskov” 😎.