punteros nulos vs. Patrón de objetos nulos

27

Atribución: Esto surgió de una pregunta relacionada con P.SE

Mi experiencia es en C / C ++, pero he trabajado bastante en Java y actualmente estoy codificando C #. Debido a mi experiencia en C, la verificación de los punteros pasados ​​y devueltos es de segunda mano, pero reconozco que sesga mi punto de vista.

Hace poco vi una mención al Patrón de objetos nulos donde la idea es que siempre se devuelve un objeto. El caso normal devuelve el objeto poblado esperado y el caso de error devuelve un objeto vacío en lugar de un puntero nulo. La premisa es que la función de llamada siempre tendrá algún tipo de objeto para acceder y, por lo tanto, evitará violaciones de memoria de acceso nulo.

  • Entonces, ¿cuáles son las ventajas y desventajas de una verificación nula frente al uso del patrón de objeto nulo?

Puedo ver un código de llamada más limpio con el NOP, pero también puedo ver dónde crearía fallas ocultas que de otro modo no se generarían. Prefiero que mi aplicación falle con dificultad (también conocida como una excepción) mientras la estoy desarrollando antes que tener un error silencioso para escapar a la naturaleza.

  • ¿No puede el patrón de objetos nulos tener problemas similares a los de no realizar una verificación nula?

Muchos de los objetos con los que he trabajado contienen objetos o contenedores propios. Parece que tendría que tener un caso especial para garantizar que todos los contenedores del objeto principal tuvieran objetos vacíos. Parece que esto podría ponerse feo con múltiples capas de anidamiento.

Comunidad
fuente
No es el "caso de error" sino "en todos los casos un objeto válido".
@ ThorbjørnRavnAndersen - buen punto, y edité la pregunta para reflejar eso. Por favor, avíseme si sigo equivocando la premisa de NOP.
66
La respuesta corta es que el "patrón de objeto nulo" es un nombre inapropiado. Se llama más exactamente el "antipatrón de objeto nulo". Por lo general, está mejor con un "objeto de error": un objeto válido que implementa la interfaz completa de la clase, pero cualquier uso del mismo provoca una muerte súbita y fuerte.
Jerry Coffin
1
@JerryCoffin ese caso debe ser indicado por una excepción. La idea es que nunca se puede obtener un valor nulo y, por lo tanto, no es necesario verificarlo.
1
No me gusta este "patrón". Pero posiblemente esté enraizado en bases de datos relacionales sobrecargadas, donde es más fácil referirse a "filas nulas" especiales en claves foráneas durante la puesta en escena de datos editables, antes de la actualización final cuando todas las claves foráneas no son nulas.

Respuestas:

24

No usaría un Patrón de objeto nulo en lugares donde se devuelve nulo (u Objeto nulo) porque hubo una falla catastrófica. En esos lugares continuaría volviendo nulo. En algunos casos, si no hay recuperación, también podría bloquearse porque al menos el volcado de bloqueo indicará exactamente dónde ocurrió el problema. En tales casos, cuando agrega su propio manejo de errores, todavía va a matar el proceso (nuevamente, dije para los casos en que no hay recuperación), pero su manejo de errores enmascarará información muy importante que un volcado por caída habría proporcionado.

El patrón de objeto nulo es más para lugares donde hay un comportamiento predeterminado que podría tomarse en un caso donde no se encuentra el objeto. Por ejemplo, considere lo siguiente:

User* pUser = GetUser( "Bob" );

if( pUser )
{
    pUser->SetAddress( "123 Fake St." );
}

Si usa NOP, escribiría:

GetUser( "Bob" )->SetAddress( "123 Fake St." );

Tenga en cuenta que el comportamiento de este código es "si Bob existe, quiero actualizar su dirección". Obviamente, si su aplicación requiere que Bob esté presente, no desea tener éxito en silencio. Pero hay casos en los que este tipo de comportamiento sería apropiado. Y en esos casos, ¿NOP no produce un código mucho más limpio y conciso?

En lugares donde realmente no puede vivir sin Bob, haría que GetUser () lanzara una excepción de aplicación (es decir, no una infracción de acceso ni nada por el estilo) que se manejaría en un nivel superior e informaría un fallo general de la operación. En este caso, no hay necesidad de NOP, pero tampoco hay necesidad de verificar explícitamente NULL. OMI, esas comprobaciones de NULL, solo hacen que el código sea más grande y eliminan la legibilidad. Verificar NULL sigue siendo la opción de diseño correcta para algunas interfaces, pero no tanto como algunas personas tienden a pensar.

DXM
fuente
44
De acuerdo con el caso de uso para patrones de objetos nulos. Pero, ¿por qué volver nulo al encontrar fallas catastróficas? ¿Por qué no lanzar excepciones?
Apoorv Khurasia
2
@MonsterTruck: invierte eso. Cuando escribo código, me aseguro de validar la mayoría de las entradas recibidas de componentes externos. Sin embargo, entre las clases internas si escribo código de modo que una función de una clase nunca devolverá NULL, entonces, en el lado de la llamada, no agregaré un cheque nulo solo para "estar más seguro". La única forma en que ese valor podría ser NULL es si se produjo un error lógico catastrófico en mi aplicación. En ese caso, quiero que mi programa se bloquee exactamente en ese punto porque luego obtengo un buen archivo de volcado de bloqueo e invierto el estado de la pila y el objeto para identificar el error lógico.
DXM
2
@MonsterTruck: con "revertir eso" quise decir, no devuelva explícitamente NULL cuando identifique un error catastrófico, pero espere que NULL solo ocurra debido a algún error catastrófico imprevisto.
DXM
Ahora entiendo lo que quieres decir con error catastrófico, algo que hace que la asignación de objetos en sí misma falle. ¿Correcto? Editar: No se puede hacer @ DXM porque su apodo tiene solo 3 caracteres de longitud.
Apoorv Khurasia
@MonsterTruck: extraño sobre la cosa "@". Sé que otras personas lo han hecho antes. Me pregunto si modificaron el sitio web recientemente. No tiene que ser asignación. Si escribo una clase y la intención de la API es siempre devolver un objeto válido, entonces no haría que la persona que realiza la llamada haga una comprobación nula. Pero un error catastrófico podría ser algo así como un error del programador (un error tipográfico que provocó la devolución de NULL), o tal vez alguna condición de carrera que borró un objeto antes de tiempo, o tal vez algún daño en la pila. Esencialmente, cualquier ruta de código inesperada que condujo a la devolución de NULL. Cuando eso sucede, quieres ...
DXM
7

Entonces, ¿cuáles son las ventajas y desventajas de una verificación nula frente al uso del patrón de objeto nulo?

Pros

  • Una verificación nula es mejor ya que resuelve más casos. No todos los objetos tienen un comportamiento sano predeterminado o no operativo.
  • Un cheque nulo es más sólido. Incluso los objetos con valores predeterminados sanos se usan en lugares donde el valor predeterminado sano no es válido. El código debería fallar cerca de la causa raíz si va a fallar. El código debería fallar obviamente si va a fallar.

Contras

  • Los valores predeterminados correctos generalmente resultan en un código más limpio.
  • Los valores predeterminados sanos generalmente resultan en errores menos catastróficos si logran entrar en la naturaleza.

Este último "profesional" es el principal diferenciador (en mi experiencia) en cuanto a cuándo se debe aplicar cada uno. "¿Debería la falla ser ruidosa?". En algunos casos, desea que un fracaso sea duro e inmediato; si algún escenario que nunca debería suceder de alguna manera lo hace. Si no se encontró un recurso vital ... etc. En algunos casos, está de acuerdo con un valor predeterminado sensato, ya que no es realmente un error : obtener un valor de un diccionario, pero falta la clave, por ejemplo.

Al igual que cualquier otra decisión de diseño, existen ventajas y desventajas según sus necesidades.

Telastyn
fuente
1
En la sección Pros: De acuerdo con el primer punto. El segundo punto es culpa de los diseñadores porque incluso nulo puede usarse donde no debería estar. No dice nada malo sobre el patrón, solo se refleja en el desarrollador que usó el patrón incorrectamente.
Apoorv Khurasia
¿Qué es más falla catástrofe, no poder enviar dinero o enviar (y por lo tanto ya no tener) dinero sin que el destinatario los reciba y no lo sepa antes del cheque explícito?
user470365
Contras: los valores predeterminados correctos se pueden usar para construir nuevos objetos. Si se devuelve un objeto no nulo, algunos de sus atributos se pueden modificar al crear otro objeto. Si se devuelve un objeto nulo, se puede usar para crear un nuevo objeto. En ambos casos, funciona el mismo código. No es necesario un código separado para copiar-modificar y nuevo. Esto es parte de la filosofía de que cada línea de código es otro lugar para errores; La reducción de las líneas reduce los errores.
shawnhcorey
@shawnhcorey: ¿qué?
Telastyn
Un objeto nulo debe ser utilizable como si fuera un objeto real. Por ejemplo, considere el NaN de IEEE (no un número). Si una ecuación sale mal, se devuelve. Y se puede usar para cálculos adicionales ya que cualquier operación en él devolverá NaN. Simplifica el código ya que no tiene que verificar NaN después de cada operación aritmética.
shawnhcorey
6

No soy una buena fuente de información objetiva, pero subjetivamente:

  • No quiero que mi sistema muera nunca; para mí es una señal de un sistema mal diseñado o incompleto; el sistema completo debe manejar todos los casos posibles y nunca entrar en estado inesperado; Para lograr esto, necesito ser explícito al modelar todos los flujos y situaciones; el uso de valores nulos no es explícito y agrupa muchos casos diferentes bajo una umrella; Prefiero el objeto NonExistingUser a nulo, prefiero NaN a nulo, ... este enfoque me permite ser explícito sobre esas situaciones y su manejo con todos los detalles que desee, mientras que nulo me deja con la única opción nula; en un entorno como Java, cualquier objeto puede ser nulo, entonces, ¿por qué preferiría ocultar en ese grupo genérico de casos también su caso específico?
  • las implementaciones nulas tienen un comportamiento drásticamente diferente que el objeto y están en su mayoría pegadas al conjunto de todos los objetos (¿por qué? ¿por qué? ¿por qué?); Para mí, una diferencia tan drástica parece ser el resultado del diseño de valores nulos como indicación de error, en cuyo caso el sistema probablemente morirá (estoy sorprendido de que muchas personas realmente prefieran que su sistema muera en las publicaciones anteriores) a menos que se maneje explícitamente; para mí, ese es un enfoque defectuoso en su raíz: permite que el sistema muera por defecto a menos que explícitamente se haya ocupado de eso, eso no es muy seguro, ¿verdad? pero en cualquier caso, es imposible escribir un código que trate el valor nulo y el objeto de la misma manera: solo funcionarán algunos operadores básicos integrados (asignar, igual, param, etc.), todos los demás códigos simplemente fallarán y necesitará dos caminos completamente diferentes;
  • El uso de objetos nulos se escala mejor: puede poner tanta información y estructura como desee; NonExistingUser es un muy buen ejemplo: puede contener un correo electrónico del usuario que intentó recibir y sugerir crear un nuevo usuario basado en esa información, mientras que con la solución nula, tendría que pensar cómo mantener el correo electrónico al que la gente intentó acceder cerca del manejo de resultados nulos;

En un entorno como Java o C #, no le dará el beneficio principal de lo explícito: la seguridad de la solución está completa, porque aún puede recibir nulo en lugar de cualquier objeto en el sistema y Java no tiene medios (excepto las anotaciones personalizadas para Beans , etc.) para protegerte de eso, excepto los if explícitos. Pero en el sistema libre de implementaciones nulas, abordar la solución del problema de tal manera (con objetos que representan casos excepcionales) brinda todos los beneficios mencionados. Por lo tanto, intente leer sobre algo distinto de los idiomas principales, y se encontrará cambiando sus formas de codificación.

En general, los objetos Nulo / Error / tipos de retorno se consideran una estrategia de manejo de errores muy sólida y matemáticamente sólida, mientras que la estrategia de manejo de excepciones de Java o C va solo un paso por encima de la estrategia de "morir lo antes posible", así que básicamente deje toda la carga de inestabilidad e imprevisibilidad del sistema en desarrollador o mantenedor.

También hay mónadas que se pueden considerar superiores a esta estrategia (también superiores en complejidad de comprensión al principio), pero se trata más de los casos en los que necesita modelar aspectos externos de tipo, mientras que Objeto nulo modela aspectos internos. Entonces NonExistingUser probablemente esté mejor modelado como monad maybe_existing.

Y, por último, no creo que haya ninguna conexión entre el patrón de objeto nulo y el manejo silencioso de situaciones de error. Debería ser al revés: debería forzarlo a modelar cada caso de error explícitamente y manejarlo en consecuencia. Ahora, por supuesto, puede decidir ser descuidado (por cualquier razón) y omitir manejar el caso de error explícitamente, pero manejarlo genéricamente, bueno, esa fue su elección explícita.

Bohdan Tsymbala
fuente
99
La razón por la que me gusta que mi aplicación falle cuando ocurre algo inesperado es porque sucedió algo inesperado. ¿Como paso? ¿Por qué? Recibo un volcado de memoria y puedo investigar y solucionar un problema potencialmente peligroso, en lugar de dejar que se oculte en el código. En C #, por supuesto, puedo detectar todas las excepciones inesperadas para intentar recuperar la aplicación, pero aun así quiero registrar el lugar donde ocurrió el problema y averiguar si es algo que necesita un manejo separado (incluso si es "no registre este error, es realmente esperado y recuperable ").
Luaan
3

Creo que es interesante observar que la viabilidad de un objeto nulo parece ser un poco diferente dependiendo de si su idioma está estáticamente o no. Ruby es un lenguaje que implementa un objeto para Null (o Nilen la terminología del lenguaje).

En lugar de recuperar un puntero a la memoria no inicializada, el lenguaje devolverá el objeto Nil. Esto se ve facilitado por el hecho de que el lenguaje se escribe dinámicamente. No se garantiza que una función siempre devuelva un int. Puede regresar Nilsi eso es lo que necesita hacer. Se vuelve más complicado y difícil hacer esto en un lenguaje estáticamente tipado, porque naturalmente espera que nulo sea aplicable a cualquier objeto que pueda llamarse por referencia. Tendría que comenzar a implementar versiones nulas de cualquier objeto que pudiera ser nulo (o ir a la ruta Scala / Haskell y tener algún tipo de envoltorio "Quizás").

MrLister mencionó el hecho de que en C ++ no se garantiza que tenga una referencia / puntero de inicialización cuando lo cree por primera vez. Al tener un objeto nulo dedicado en lugar de un puntero nulo sin procesar, puede obtener algunas características adicionales agradables. Ruby Nilincluye una función to_s(toString) que da como resultado un texto en blanco. Esto es excelente si no le importa obtener un retorno nulo en una cadena y desea omitir el informe sin fallar. Las clases abiertas también le permiten anular manualmente la salida de Nilsi es necesario.

De acuerdo, todo esto se puede tomar con un grano de sal. Creo que en un lenguaje dinámico, el objeto nulo puede ser una gran conveniencia cuando se usa correctamente. Desafortunadamente, aprovecharlo al máximo puede requerir que edite su funcionalidad básica, lo que podría hacer que su programa sea difícil de entender y mantener para las personas acostumbradas a trabajar con sus formularios más estándar.

KChaloux
fuente
1

Para obtener un punto de vista adicional, eche un vistazo a Objective-C, donde en lugar de tener un objeto nulo, el valor nil funciona como un objeto. Cualquier mensaje enviado a un objeto nulo no tiene efecto y devuelve un valor de 0 / 0.0 / NO (falso) / NULL / nil, cualquiera que sea el tipo de valor de retorno del método. Esto se usa como un patrón muy generalizado en la programación de MacOS X e iOS, y generalmente los métodos se diseñarán de manera que un objeto nulo que devuelva 0 sea un resultado razonable.

Por ejemplo, si desea verificar si una cadena tiene uno o más caracteres, puede escribir

aString.length > 0

y obtenga un resultado razonable si aString == nil, porque una cadena no existente no contiene ningún carácter. La gente tiende a tomarse un tiempo para acostumbrarse, pero probablemente me tomaría un tiempo acostumbrarme a verificar valores nulos en otros idiomas.

(También hay un objeto singleton de clase NSNull que se comporta de manera bastante diferente y no se usa mucho en la práctica).

gnasher729
fuente
Objective-C hizo que el objeto Null Object / Null Pointer se volviera realmente borroso. nilllegar #definea NULL y se comporta como un objeto nulo. Esa es una característica de tiempo de ejecución mientras que en C # / Java los punteros nulos emiten errores.
Maxthon Chan
0

No lo use tampoco si puede evitarlo. Utilice una función de fábrica que devuelva un tipo de opción, una tupla o un tipo de suma dedicada. En otras palabras, represente un valor potencialmente predeterminado con un tipo diferente de un valor garantizado que no debe ser predeterminado.

Primero, enumeremos los desiderata, luego pensemos cómo podemos expresar esto en algunos lenguajes (C ++, OCaml, Python)

  1. detectar el uso no deseado de un objeto predeterminado en tiempo de compilación.
  2. haga obvio si un valor dado está potencialmente predeterminado o no mientras lee el código.
  3. elija nuestro valor predeterminado razonable, si corresponde, una vez por tipo . Definitivamente no elija un valor predeterminado potencialmente diferente en cada sitio de llamada .
  4. facilite que las herramientas de análisis estático o un humano grepbusquen posibles errores.
  5. Para algunas aplicaciones , el programa debería continuar normalmente si inesperadamente se le da un valor predeterminado. Para otras aplicaciones , el programa debe detenerse inmediatamente si se le da un valor predeterminado, idealmente de manera informativa.

Creo que la tensión entre el Patrón de objetos nulos y los punteros nulos proviene de (5). Sin embargo, si podemos detectar errores lo suficientemente temprano, (5) se vuelve discutible.

Consideremos este idioma por idioma:

C ++

En mi opinión, una clase de C ++ generalmente debería ser construible por defecto, ya que facilita las interacciones con las bibliotecas y facilita el uso de la clase en contenedores. También simplifica la herencia ya que no necesita pensar a qué constructor de superclase llamar.

Sin embargo, esto significa que no puede saber con certeza si un valor de tipo MyClassestá en el "estado predeterminado" o no. Además de poner en un bool nonemptycampo o similar a la superficie predeterminada en tiempo de ejecución, lo mejor que puede hacer es producir nuevas instancias de MyClassuna manera que obligue al usuario a verificarlo .

Recomiendo usar una función de fábrica que devuelva a std::optional<MyClass>, std::pair<bool, MyClass>o una referencia de valor r a a std::unique_ptr<MyClass>si es posible.

Si desea que su función de fábrica devuelva algún tipo de estado de "marcador de posición" que sea distinto de un estado predeterminado MyClass, use std::pairay asegúrese de documentar que su función lo hace.

Si la función de fábrica tiene un nombre único, es fácil greppara el nombre y buscar descuido. Sin embargo, es difícil greppara los casos en que el programador debería haber utilizado la función de fábrica pero no lo hizo .

OCaml

Si está utilizando un lenguaje como OCaml, puede usar un optiontipo (en la biblioteca estándar de OCaml) o un eithertipo (no en la biblioteca estándar, pero fácil de usar ). O un defaultabletipo (estoy inventando el término defaultable).

type 'a option =
    | None
    | Some of 'a

type ('a, 'b) either =
    | Left of 'a
    | Right of 'b

type 'a defaultable =
    | Default of 'a
    | Real of 'a

A defaultablecomo se muestra arriba es mejor que un par porque el usuario debe combinar patrones para extraer el 'ay no puede simplemente ignorar el primer elemento del par.

El equivalente del defaultabletipo que se muestra arriba se puede usar en C ++ usando a std::variantcon dos instancias del mismo tipo, pero partes de la std::variantAPI no se pueden usar si ambos tipos son iguales. También es un uso extraño std::variantya que sus constructores tipo no tienen nombre.

Pitón

De todos modos, no obtiene ninguna comprobación de tiempo de compilación para Python. Pero, al escribir de forma dinámica, generalmente no hay circunstancias en las que necesite una instancia de marcador de posición de algún tipo para aplacar el compilador.

Recomendaría simplemente lanzar una excepción cuando se vea obligado a crear una instancia predeterminada.

Si eso no es aceptable, recomendaría crear uno DefaultedMyClassque herede MyClassy devolverlo de su función de fábrica. Eso le da flexibilidad en términos de funcionalidad de "desconexión" en casos predeterminados si es necesario.

Gregory Nisbet
fuente