Los valores nulos, cuando se usan sin pensar, pueden hacer que su vida sea insoportable y es posible que ni siquiera comprenda qué es exactamente lo que les causa tal dolor. Déjame explicarte.
Valores por defecto
Todos vimos un método que toma muchos argumentos, pero más de la mitad de ellos son opcionales. El resultado es algo como esto:
public function insertDiscount( string $name, int $amountInCents, bool $isActive = true, string $description = '', int $productIdConstraint = null, DateTimeImmutable $startDateConstraint = null, DateTimeImmutable $endDateConstraint = null, int $paymentMethodConstraint = null ): int
En el ejemplo anterior, queremos crear un descuento que se aplique en todas partes de manera predeterminada, pero puede estar inactivo al crear, aplicar solo a productos específicos, actuar solo en ciertos momentos o aplicar cuando el usuario selecciona un método de pago específico.
Si desea crear un descuento para un determinado método de pago, deberá llamar al método de la siguiente manera:
insertDiscount('Discount name', 100, true, '', null, null, null, 5);
Este código funcionará, pero es completamente incomprensible para la persona que lo lee. Se vuelve extremadamente difícil analizarlo, por lo que no podemos admitir fácilmente la aplicación.
Tomemos este ejemplo argumento por argumento.
¿Qué es un descuento válido?
Ya hemos descubierto que el descuento ilimitado se aplica en todas partes. Por lo tanto, un descuento válido contiene todo excepto las restricciones que podemos agregar más adelante. El argumento isActive tiene un valor predeterminado de verdadero. Por lo tanto, el método se puede llamar de la siguiente manera:
insertDiscount('Discount name', 100);
Simplemente leyendo el código, no sé si el descuento estará activo de inmediato. Para averiguarlo, tendría que verificar si la firma del método tiene valores predeterminados.
Ahora imagine que necesita leer 200 líneas de código. ¿Está seguro de que desea verificar cada firma del método llamado para obtener información oculta? Prefiero leer el código sin tener que buscar nada.
Lo mismo ocurre con el argumento responsable de la descripción. Por defecto, es una cadena vacía; esto puede causar muchos problemas en el lugar donde espera ver una descripción. Por ejemplo, puede imprimirse en el cheque, pero como está vacío, el usuario simplemente ve una línea vacía al lado de la línea con la cantidad. El sistema no debe permitir que esto suceda.
Reescribiría este método así:
public function insertDiscount( string $name, string $description, int $amountInCents, bool $isActive ): int
Eliminé completamente las restricciones, ya que decidimos agregarlas más tarde usando métodos separados. Como ahora se requieren todos los parámetros, se pueden organizar en cualquier orden. Puse la descripción justo después del nombre, porque el código se lee mejor cuando están cerca.
insertDiscount( 'Discount name', 'Discount description', 100, Discount::STATUS_ACTIVE );
También usé constantes para el estado de la actividad de descuento. Ahora no es necesario que mire la firma del método para averiguar qué significa realmente este argumento: resulta obvio que estamos creando un descuento activo. En el futuro, podemos mejorar aún más este método (spoiler: usar objetos de valor).
Agregar restricciones
Ahora puede agregar varias restricciones. Para evitar cero, cero, cero infierno, crearemos métodos separados.
public function addProductConstraint( Discount $discount, int $productId ): Discount; public function addDateConstraint( Discount $discount, DateTimeImmutable $startDate, DateTimeImmutable $endDate ): Discount; public function addPaymentMethodConstraint( Discount $discount, int $paymentMethod ): Discount;
Por lo tanto, si queremos crear un nuevo descuento con ciertas restricciones, lo haremos así:
$discountId = insertDiscount( 'Discount name', 'Discount description', 100, Discount::STATUS_ACTIVE ); addPaymentMethodConstraint( $discountId, PaymentMethod::CREDIT_CARD );
Ahora compare esto con la llamada original. Verá cuánto más conveniente se ha vuelto para leer.
Nulo en propiedades de objeto
Resolver ceros en las propiedades del objeto también causa problemas. No puedo transmitir con qué frecuencia veo tales cosas:
$currencyCode = strtolower( $record->currencyCode );
Buum! "No se puede pasar nulo a strtolower". Esto sucedió porque el desarrollador olvidó que currencyCode puede ser nulo. Dado que muchos desarrolladores aún no usan el IDE o suprimen las advertencias en ellos, esto puede pasar desapercibido durante muchos años. El error continuará apareciendo en algún registro no leído, y los clientes informarán periódicamente de problemas que aparentemente no están relacionados con esto, por lo que nadie se molestará en mirar esta línea de código.
Podemos, por supuesto, agregar cheques nulos donde accedemos a currencyCode. Pero luego terminaremos en un tipo diferente de infierno:
if ($record->currencyCode === null) { throw new \RuntimeException('Currency code cannot be null'); } if ($record->amount === null) { throw new \RuntimeException('Amount cannot be null'); } if ($record->amount > 0) { throw new \RuntimeException('Amount must be a positive value'); }
Pero, como ya entendió, esta no es la mejor solución. Además de abarrotar su método, ahora debe repetir esta prueba en todas partes. Y cada vez que agregue otra propiedad nula, ¡no olvide hacer otra verificación de este tipo! Afortunadamente, hay una solución simple: objetos de valor.
Objetos de valor
Los objetos de valor son cosas poderosas pero simples. El problema que estábamos tratando de resolver era que es necesario validar constantemente todas nuestras propiedades. Pero hacemos esto porque no sabemos si es posible confiar en las propiedades del objeto, si son válidas. ¿Y si pudiéramos?
Para confiar en los valores, necesitan dos atributos: deben estar validados y no deberían haber cambiado desde la validación. Echa un vistazo a esta clase:
final class Amount { private $amountInCents; private $currencyCode; public function __construct(int $amountInCents, string $currencyCode): self { Assert::that($amountInCents)->greaterThan(0); $this->amountInCents = $amountInCents; $this->currencyCode = $currencyCode; } public function getAmountInCents(): int { return $this->amountInCents; } public function getCurrencyCode(): string { return $this->currencyCode; } }
Estoy usando el paquete beberlei / afirmar. Lanza una excepción cada vez que falla la verificación. Esto es lo mismo que la excepción para nulo en el código fuente, a menos que hayamos movido la verificación a este constructor.
Como utilizamos declaraciones de tipo, garantizamos que el tipo también es correcto. Por lo tanto, no podemos pasar int a strtolower. Si está utilizando una versión anterior de PHP que no admite declaraciones de tipo, puede usar este paquete para verificar los tipos con -> integer () y -> string ().
Después de crear el objeto, los valores no se pueden cambiar, porque solo tenemos getters, pero no setters. Esto se llama inmunidad. Agregar final no permite extender esta clase para agregar setters o métodos mágicos. Si ve Cantidad $ cantidad en los parámetros del método, puede estar 100% seguro de que todas sus propiedades han sido validadas y que el objeto es seguro de usar. Si los valores no fueran válidos, no podríamos crear un objeto.
Ahora, con la ayuda de objetos de valor, podemos mejorar aún más nuestro ejemplo:
$discount = new Discount( 'Discount name', 'Discount description', new Amount(100, 'CAD'), Discount::STATUS_ACTIVE ) insertDiscount($discount);
Tenga en cuenta que primero creamos un descuento, y en el interior usamos Amount como argumento. Esto garantiza que el método insertDiscount reciba un objeto de descuento válido además de hacer que este bloque de código sea mucho más fácil de entender.
Zero Horror Story
Veamos un caso interesante en el que nulo puede ser dañino en una aplicación. La idea es extraer la colección de la base de datos y filtrarla.
$collection = $this->findBy(['key' => 'value']); $result = $this->filter($collection, $someFilterMethod); if ($result === null) { $result = $collection; }
Si el resultado es nulo, ¿usa la colección original como resultado? Esto es problemático, ya que el método de filtrado devuelve nulo si no encuentra valores adecuados. Por lo tanto, si todo se filtra, ignoraremos el filtro y devolveremos todos los valores. Esto rompe completamente la lógica.
¿Por qué se utiliza la colección original como resultado? Nunca lo sabremos Sospecho que el desarrollador tenía una cierta suposición sobre lo que significa nulo en este contexto, pero resultó ser incorrecto.
Este es el problema con los valores nulos. En la mayoría de los casos, no está claro qué significan y, por lo tanto, solo podemos adivinar cómo responderles. Es muy fácil cometer un error. La excepción, por otro lado, es muy clara:
try { $result = $this->filter($collection, $someFilterMethod); } catch (CollectionCannotBeEmpty $e) {
Este código es único. Es poco probable que un desarrollador lo malinterprete.
¿Vale la pena el esfuerzo?
Todo esto parece un esfuerzo extra para escribir código que haga lo mismo. Si lo es. Pero al mismo tiempo, pasará mucho menos tiempo leyendo y entendiendo el código, por lo que los esfuerzos serán bastante recompensados. Cada hora adicional que paso escribiendo el código correctamente me ahorra días en los que no tengo que cambiar el código o agregar nuevas funciones. Piense en ello como una inversión garantizada de alto rendimiento.
Así que ha llegado el final de mi diatriba nula. Espero que esto te ayude a escribir código más comprensible y mantenible.