La mejor explicación para idiomas sin nulo

Cada cierto tiempo, cuando los progtwigdores se quejan de errores nulos / excepciones, alguien pregunta qué hacemos sin nulo.

Tengo una idea básica de la frescura de los tipos de opciones, pero no tengo la habilidad de conocimientos o idiomas para expresslo mejor. ¿Cuál es una gran explicación de lo siguiente escrito de una manera accesible para el progtwigdor promedio a la que podamos dirigir?

  • La inconveniencia de tener referencias / punteros puede ser anulada por defecto
  • Cómo funcionan los tipos de opciones, incluidas las estrategias para facilitar la comprobación de casos nulos, como
    • coincidencia de patrones y
    • comprensión monádica
  • Una solución alternativa como el mensaje de comer nada
  • (otros aspectos que me perdí)

Creo que el resumen sucinto de por qué nulo es indeseable es que los estados sin significado no deberían ser representables .

Supongamos que estoy modelando una puerta. Puede estar en uno de tres estados: abierto, cerrado pero desbloqueado, cerrado y bloqueado. Ahora podría modelarlo a lo largo de las líneas de

class Door private bool isShut private bool isLocked 

y está claro cómo mapear mis tres estados en estas dos variables booleanas. Pero esto deja un cuarto estado indeseado disponible: isShut==false && isLocked==true . Debido a que los tipos que he seleccionado como mi representación admiten este estado, debo esforzarme mentalmente para asegurar que la clase nunca entre en este estado (tal vez codificando explícitamente un invariante). Por el contrario, si estuviera usando un lenguaje con tipos de datos algebraicos o enumeraciones controladas, eso me permite definir

 type DoorState = | Open | ShutAndUnlocked | ShutAndLocked 

entonces podría definir

 class Door private DoorState state 

y no hay más preocupaciones El sistema de tipo asegurará que solo haya tres estados posibles para una instancia de class Door de class Door . Este es el tipo de sistema que es bueno: descartar explícitamente una clase completa de errores en tiempo de comstackción.

El problema con null es que cada tipo de referencia obtiene este estado adicional en su espacio que normalmente no se desea. Una variable de string podría ser cualquier secuencia de caracteres, o podría ser este loco valor extra null que no se asigna a mi dominio problemático. Un objeto Triangle tiene tres Point s, que a su vez tienen valores X e Y , pero desafortunadamente el Point s o el Triangle sí mismo podría ser este loco valor nulo que no tiene sentido para el dominio de gráficos en el que estoy trabajando. Etc.

Cuando tiene la intención de modelar un valor posiblemente inexistente, debe optar por hacerlo explícitamente. Si la forma en que pretendo modelar personas es que cada Person tiene un FirstName y un LastName , pero solo algunas personas tienen MiddleName , entonces me gustaría decir algo como

 class Person private string FirstName private Option MiddleName private string LastName 

donde se supone que la string aquí es un tipo que no admite nulos. Entonces no hay invariantes difíciles de establecer y ninguna NullReferenceException inesperada al tratar de calcular la longitud del nombre de alguien. El sistema de tipos garantiza que cualquier código que trate con MiddleName cuenta la posibilidad de que sea None , mientras que cualquier código que trate con FirstName puede asumir con seguridad que hay un valor allí.

Entonces, por ejemplo, usando el tipo anterior, podríamos crear esta tonta función:

 let TotalNumCharsInPersonsName(p:Person) = let middleLen = match p.MiddleName with | None -> 0 | Some(s) -> s.Length p.FirstName.Length + middleLen + p.LastName.Length 

sin preocupaciones Por el contrario, en un lenguaje con referencias anulables para tipos como cadena, entonces asumiendo

 class Person private string FirstName private string MiddleName private string LastName 

terminas escribiendo cosas como

 let TotalNumCharsInPersonsName(p:Person) = p.FirstName.Length + p.MiddleName.Length + p.LastName.Length 

que explota si el objeto Persona entrante no tiene la invariante de que todo es no nulo, o

 let TotalNumCharsInPersonsName(p:Person) = (if p.FirstName=null then 0 else p.FirstName.Length) + (if p.MiddleName=null then 0 else p.MiddleName.Length) + (if p.LastName=null then 0 else p.LastName.Length) 

o tal vez

 let TotalNumCharsInPersonsName(p:Person) = p.FirstName.Length + (if p.MiddleName=null then 0 else p.MiddleName.Length) + p.LastName.Length 

suponiendo que p asegura que el primero / el último están ahí, pero el medio puede ser nulo, o tal vez usted hace controles que arrojan diferentes tipos de excepciones, o quién sabe qué. Todas estas locas opciones de implementación y cosas para pensar surgen porque hay un estúpido valor representable que no quieres o no necesitas.

Null típicamente agrega complejidad innecesaria. La complejidad es el enemigo de todo el software, y debe esforzarse por reducir la complejidad siempre que sea razonable.

(Nótese bien que hay más complejidad para incluso estos ejemplos simples. Incluso si un FirstName no puede ser null , una string puede representar "" (la cadena vacía), que probablemente tampoco sea el nombre de persona que pretendemos modelar. , incluso con cadenas que no admiten nulos, todavía podría ser el caso de que estamos “representando valores sin sentido”. De nuevo, puede optar por combatir esto mediante invariantes y código condicional en tiempo de ejecución, o mediante el uso del sistema de tipo (por ejemplo, tener un tipo NonEmptyString ). El último es tal vez desacertado (los tipos “buenos” a menudo se “cierran” sobre un conjunto de operaciones comunes, y por ejemplo NonEmptyString no se cierra sobre .SubString(0,0) ), pero demuestra más puntos en el espacio de diseño. Al final del día, en cualquier sistema de tipo dado, hay una cierta complejidad de la que será muy bueno deshacerse, y otra complejidad que es intrínsecamente más difícil de eliminar. La clave de este tema es que en casi todos los sistemas de tipo, el cambio de “referencias anulables por defecto” a “referencias no anulables por defecto” es casi siempre un cambio simple que hace que el sistema de tipo sea mucho mejor para combatir la complejidad y descartar ciertos tipos de errores y estados sin sentido. Así que es bastante loco que tantos idiomas sigan repitiendo este error una y otra vez).

Lo bueno de los tipos de opciones no es que sean opcionales. Es que todos los demás tipos no lo son .

Algunas veces , necesitamos poder representar un tipo de estado “nulo”. Algunas veces tenemos que representar una opción de “no valor” así como los otros valores posibles que una variable puede tomar. Entonces, un lenguaje que no permite que esto vaya a ser un poco paralizado.

Pero a menudo , no lo necesitamos, y permitir ese estado “nulo” solo lleva a ambigüedad y confusión: cada vez que accedo a una variable de tipo de referencia en .NET, debo considerar que podría ser nulo .

A menudo, nunca será nulo, porque el progtwigdor estructura el código para que nunca pueda suceder. Pero el comstackdor no puede verificarlo, y cada vez que lo ve, debe preguntarse a sí mismo “¿puede ser nulo? ¿Necesito comprobar nulo aquí?”

Idealmente, en los muchos casos en que null no tiene sentido, no debería permitirse .

Eso es complicado de lograr en .NET, donde casi todo puede ser nulo. Debe confiar en que el autor del código que está llamando es 100% disciplinado y consecuente y ha documentado claramente lo que puede y lo que no puede ser nulo, o tiene que ser paranoico y verificar todo .

Sin embargo, si los tipos no son anulables por defecto , entonces no necesita verificar si son nulos o no. Usted sabe que nunca pueden ser nulos, porque el comstackdor / verificador de tipos lo hace cumplir por usted.

Y luego solo necesitamos una puerta trasera para los raros casos en los que necesitamos manejar un estado nulo. Entonces se puede usar un tipo de “opción”. Luego, permitimos la nulidad en los casos en que hemos tomado una decisión consciente de que tenemos que ser capaces de representar el caso “sin valor”, y en cualquier otro caso, sabemos que el valor nunca será nulo.

Como otros han mencionado, en C # o Java, por ejemplo, null puede significar una de dos cosas:

  1. la variable no está inicializada Esto debería, idealmente, nunca suceder. Una variable no debería existir a menos que esté inicializada.
  2. la variable contiene algunos datos “opcionales”: debe poder representar el caso donde no hay datos . Esto a veces es necesario. Tal vez estás tratando de encontrar un objeto en una lista, y no sabes de antemano si está allí o no. Entonces tenemos que ser capaces de representar que “no se encontró ningún objeto”.

El segundo significado debe ser preservado, pero el primero debe ser eliminado por completo. E incluso el segundo significado no debería ser el predeterminado. Es algo en lo que podemos participar si lo necesitamos . Pero cuando no necesitamos que algo sea opcional, queremos que el verificador de tipos garantice que nunca será nulo.

Todas las respuestas hasta ahora se centran en por qué null es algo malo, y en cómo es útil si un lenguaje puede garantizar que ciertos valores nunca serán nulos.

Luego continúan sugiriendo que sería una idea muy clara si aplica la no obligatoriedad para todos los valores, lo que se puede hacer si agrega un concepto como Option o Maybe para representar tipos que pueden no tener siempre un valor definido. Este es el enfoque adoptado por Haskell.

¡Todo es bueno! Pero no excluye el uso de tipos explícitamente nulos / no nulos para lograr el mismo efecto. ¿Por qué, entonces, la Opción sigue siendo una buena cosa? Después de todo, Scala admite valores anulables (es necesario, por lo que puede funcionar con bibliotecas Java) pero también admite Options .

P. ¿Cuáles son los beneficios más allá de poder eliminar nulos de un idioma por completo?

A. Composición

Si realiza una traducción ingenua del código nulo

 def fullNameLength(p:Person) = { val middleLen = if (null == p.middleName) p.middleName.length else 0 p.firstName.length + middleLen + p.lastName.length } 

al código de opción-consciente

 def fullNameLength(p:Person) = { val middleLen = p.middleName match { case Some(x) => x.length case _ => 0 } p.firstName.length + middleLen + p.lastName.length } 

no hay mucha diferencia! Pero también es una forma terrible de usar Opciones … Este enfoque es mucho más limpio:

 def fullNameLength(p:Person) = { val middleLen = p.middleName map {_.length} getOrElse 0 p.firstName.length + middleLen + p.lastName.length } 

O incluso:

 def fullNameLength(p:Person) = p.firstName.length + p.middleName.map{length}.getOrElse(0) + p.lastName.length 

Cuando empiezas a lidiar con la Lista de Opciones, se pone aún mejor. Imagine que la people la lista es en sí misma opcional:

 people flatMap(_ find (_.firstName == "joe")) map (fullNameLength) 

¿Como funciona esto?

 //convert an Option[List[Person]] to an Option[S] //where the function f takes a List[Person] and returns an S people map f //find a person named "Joe" in a List[Person]. //returns Some[Person], or None if "Joe" isn't in the list validPeopleList find (_.firstName == "joe") //returns None if people is None //Some(None) if people is valid but doesn't contain Joe //Some[Some[Person]] if Joe is found people map (_ find (_.firstName == "joe")) //flatten it to return None if people is None or Joe isn't found //Some[Person] if Joe is found people flatMap (_ find (_.firstName == "joe")) //return Some(length) if the list isn't None and Joe is found //otherwise return None people flatMap (_ find (_.firstName == "joe")) map (fullNameLength) 

El código correspondiente con controles nulos (o incluso elvis?: Operadores) sería dolorosamente largo. El verdadero truco aquí es la operación flatMap, que permite la comprensión anidada de Opciones y colecciones de una manera que los valores nulos nunca pueden alcanzar.

Dado que las personas parecen estar perdiendo: null es ambiguo.

La fecha de nacimiento de Alice es null . Qué significa eso?

La fecha de fallecimiento de Bob es null . Qué significa eso?

Una interpretación “razonable” podría ser que la fecha de nacimiento de Alicia existe, pero se desconoce, mientras que la fecha de la muerte de Bob no existe (Bob todavía está vivo). ¿Pero por qué llegamos a diferentes respuestas?


Otro problema: null es un caso marginal.

  • ¿Es null = null ?
  • Es nan = nan ?
  • Es inf = inf ?
  • ¿Es +0 = -0 ?
  • ¿Es +0/0 = -0/0 ?

Las respuestas suelen ser “sí”, “no”, “sí”, “sí”, “no”, “sí”, respectivamente. Los “matemáticos” locos llaman a NaN “nulidad” y dicen que se compara a sí mismo. SQL trata los nulos como no equivalentes a nada (por lo que se comportan como NaN). Uno se pregunta qué sucede cuando intenta almacenar ± ∞, ± 0 y NaN en la misma columna de la base de datos (hay 2 53 NaN, la mitad de los cuales son “negativos”).

Para empeorar las cosas, las bases de datos difieren en cómo tratan a NULL, y la mayoría de ellas no son consistentes (consulte Manejo NULL en SQLite para obtener una descripción general). Es bastante horrible.


Y ahora para la historia obligatoria:

Hace poco diseñé una tabla de base de datos (sqlite3) con cinco columnas: a NOT NULL, b, id_a, id_b NOT NULL, timestamp . Debido a que es un esquema genérico diseñado para resolver un problema genérico para aplicaciones bastante arbitrarias, existen dos restricciones de exclusividad:

 UNIQUE(a, b, id_a) UNIQUE(a, b, id_b) 

id_a solo existe para compatibilidad con un diseño de aplicación existente (en parte porque no he encontrado una mejor solución) y no se usa en la nueva aplicación. Debido a la forma en que NULL funciona en SQL, puedo insertar (1, 2, NULL, 3, t) y (1, 2, NULL, 4, t) y no violar la primera restricción de unicidad (porque (1, 2, NULL) != (1, 2, NULL) ).

Esto funciona específicamente debido a la forma en que NULL funciona en una restricción de exclusividad en la mayoría de las bases de datos (es de suponer que es más fácil modelar situaciones del “mundo real”; por ejemplo, dos personas no pueden tener el mismo Número de Seguridad Social, pero no todas).


FWIW, sin invocar primero un comportamiento indefinido, las referencias de C ++ no pueden “apuntar a” nulas, y no es posible construir una clase con variables de miembros de referencia no inicializadas (si se lanza una excepción, la construcción falla).

Nota: Ocasionalmente, es posible que desee punteros mutuamente exclusivos (es decir, solo uno de ellos puede ser no nulo), por ejemplo, en un type DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed iOS hipotético type DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed type DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed type DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed . En cambio, me veo obligado a hacer cosas como assert((bool)actionSheet + (bool)alertView == 1) .

La inconveniencia de tener referencias / punteros puede ser anulada por defecto.

No creo que este sea el principal problema con los nulos, el principal problema con los nulos es que pueden significar dos cosas:

  1. La referencia / puntero no está inicializado: el problema aquí es lo mismo que la mutabilidad en general. Por un lado, hace que sea más difícil analizar su código.
  2. El hecho de que la variable sea nula en realidad significa algo: este es el caso en el que los tipos de Opción realmente se formalizan.

Los idiomas que admiten tipos de opciones también suelen prohibir o desalentar el uso de variables no inicializadas.

Cómo funcionan los tipos de opciones, incluidas las estrategias para facilitar la comprobación de casos nulos, como la coincidencia de patrones.

Para ser eficaz, los tipos de opciones deben ser compatibles directamente en el idioma. De lo contrario, se necesita mucho código de placa de caldera para simularlos. La coincidencia de patrones y la inferencia de tipos son dos características del lenguaje de teclas que facilitan el trabajo de los tipos de opciones. Por ejemplo:

En F #:

 //first we create the option list, and then filter out all None Option types and //map all Some Option types to their values. See how type-inference shines. let optionList = [Some(1); Some(2); None; Some(3); None] optionList |> List.choose id //evaluates to [1;2;3] //here is a simple pattern-matching example //which prints "1;2;None;3;None;". //notice how value is extracted from op during the match optionList |> List.iter (function Some(value) -> printf "%i;" value | None -> printf "None;") 

Sin embargo, en un lenguaje como Java sin soporte directo para los tipos de opciones, tendríamos algo como:

 //here we perform the same filter/map operation as in the F# example. List> optionList = Arrays.asList(new Some(1),new Some(2),new None(),new Some(3),new None()); List filteredList = new ArrayList(); for(Option op : list) if(op instanceof Some) filteredList.add(((Some)op).getValue()); 

Una solución alternativa como el mensaje de comer nada

El “mensaje nulo” de Objective-C no es tanto una solución como un bash de aliviar el dolor de cabeza de la comprobación nula. Básicamente, en lugar de lanzar una excepción de tiempo de ejecución al intentar invocar un método en un objeto nulo, la expresión evalúa en cambio a nulo. Al suspender la incredulidad, es como si cada método de instancia comienza con if (this == null) return null; . Pero luego hay pérdida de información: no se sabe si el método devolvió nulo porque es un valor de retorno válido, o porque el objeto es realmente nulo. Es muy similar a la deglución de excepción, y no hace ningún progreso al abordar los problemas con null descritos anteriormente.

Assembly nos trajo direcciones también conocidas como punteros sin tipo. C los mapeó directamente como punteros tipeados, pero introdujo el valor nulo de Algol como un valor de puntero único, compatible con todos los punteros tipeados. El gran problema con nulo en C es que dado que cada puntero puede ser nulo, uno nunca puede usar un puntero de forma segura sin una verificación manual.

En lenguajes de nivel superior, tener nulo es incómodo ya que transmite dos nociones distintas:

  • Diciendo que algo no está definido .
  • Diciendo que algo es opcional .

Tener variables indefinidas es bastante inútil, y cede a un comportamiento indefinido cada vez que ocurren. Supongo que todos estarán de acuerdo en que tener cosas indefinidas debe evitarse a toda costa.

El segundo caso es opcional y se ofrece mejor explícitamente, por ejemplo, con un tipo de opción .


Digamos que estamos en una empresa de transporte y tenemos que crear una aplicación para ayudar a crear un cronogtwig para nuestros conductores. Para cada controlador, almacenamos algunas informaciones tales como: las licencias de conducir que tienen y el número de teléfono para llamar en caso de emergencia.

En C podríamos tener:

 struct PhoneNumber { ... }; struct MotorbikeLicence { ... }; struct CarLicence { ... }; struct TruckLicence { ... }; struct Driver { char name[32]; /* Null terminated */ struct PhoneNumber * emergency_phone_number; struct MotorbikeLicence * motorbike_licence; struct CarLicence * car_licence; struct TruckLicence * truck_licence; }; 

Como observa, en cualquier procesamiento sobre nuestra lista de controladores, tendremos que verificar si hay punteros nulos. El comstackdor no lo ayudará, la seguridad del progtwig depende de sus hombros.

En OCaml, el mismo código se vería así:

 type phone_number = { ... } type motorbike_licence = { ... } type car_licence = { ... } type truck_licence = { ... } type driver = { name: string; emergency_phone_number: phone_number option; motorbike_licence: motorbike_licence option; car_licence: car_licence option; truck_licence: truck_licence option; } 

Digamos ahora que queremos imprimir los nombres de todos los controladores junto con sus números de licencia de camión.

Cª:

 #include  void print_driver_with_truck_licence_number(struct Driver * driver) { /* Check may be redundant but better be safe than sorry */ if (driver != NULL) { printf("driver %s has ", driver->name); if (driver->truck_licence != NULL) { printf("truck licence %04d-%04d-%08d\n", driver->truck_licence->area_code driver->truck_licence->year driver->truck_licence->num_in_year); } else { printf("no truck licence\n"); } } } void print_drivers_with_truck_licence_numbers(struct Driver ** drivers, int nb) { if (drivers != NULL && nb >= 0) { int i; for (i = 0; i < nb; ++i) { struct Driver * driver = drivers[i]; if (driver) { print_driver_with_truck_licence_number(driver); } else { /* Huh ? We got a null inside the array, meaning it probably got corrupt somehow, what do we do ? Ignore ? Assert ? */ } } } else { /* Caller provided us with erroneous input, what do we do ? Ignore ? Assert ? */ } } 

En OCaml eso sería:

 open Printf (* Here we are guaranteed to have a driver instance *) let print_driver_with_truck_licence_number driver = printf "driver %s has " driver.name; match driver.truck_licence with | None -> printf "no truck licence\n" | Some licence -> (* Here we are guaranteed to have a licence *) printf "truck licence %04d-%04d-%08d\n" licence.area_code licence.year licence.num_in_year (* Here we are guaranteed to have a valid list of drivers *) let print_drivers_with_truck_licence_numbers drivers = List.iter print_driver_with_truck_licence_number drivers 

Como puede ver en este ejemplo trivial, no hay nada complicado en la versión segura:

  • Es terser.
  • Obtendrá garantías mucho mejores y no se requiere ningún control nulo en absoluto.
  • El comstackdor se aseguró de que trataste correctamente la opción

Mientras que en C, podrías haber olvidado un cheque nulo y un boom ...

Nota: estas muestras de código no fueron comstackdas, pero espero que tengas las ideas.

Microsoft Research tiene un proyecto intersting llamado

Especulación#

Es una extensión de C # con un tipo no nulo y un mecanismo para verificar que sus objetos no sean nulos , aunque, en mi humilde opinión, aplicar el principio de diseño por contrato puede ser más apropiado y más útil para muchas situaciones problemáticas causadas por referencias nulas.

Robert Nystrom ofrece un buen artículo aquí:

http://journal.stuffwithstuff.com/2010/08/23/void-null-maybe-and-nothing/

describiendo su proceso de pensamiento al agregar soporte para ausencia y falla a su lenguaje de progtwigción Magpie .

Viniendo de .NET de fondo, siempre pensé que null tenía un punto, es útil. Hasta que llegué a conocer las estructuras y lo fácil que era trabajar con ellas para evitar un montón de código repetitivo. Tony Hoare hablando en QCon London en 2009, se disculpó por inventar la referencia nula . Para citarlo:

Lo llamo mi error billonario. Fue la invención de la referencia nula en 1965. En ese momento, estaba diseñando el primer sistema de tipo completo para referencias en un lenguaje orientado a objetos (ALGOL W). Mi objective era garantizar que todo el uso de las referencias sea absolutamente seguro, con una verificación realizada automáticamente por el comstackdor. Pero no pude resistir la tentación de poner una referencia nula, simplemente porque era muy fácil de implementar. Esto ha llevado a innumerables errores, vulnerabilidades y fallas en el sistema, que probablemente hayan causado mil millones de dolores y daños en los últimos cuarenta años. En los últimos años, varios analizadores de progtwigs como PREfix y PREfast en Microsoft se han utilizado para verificar referencias y dar advertencias si existe el riesgo de que no sean nulas. Los lenguajes de progtwigción más recientes como Spec # han introducido declaraciones para referencias no nulas. Esta es la solución, que rechacé en 1965.

Ver esta pregunta también en los progtwigdores

Siempre he considerado nulo (o nulo) como la ausencia de un valor .

A veces quieres esto, a veces no quieres. Depende del dominio con el que estés trabajando. Si la ausencia es significativa: no hay un segundo nombre, entonces su aplicación puede actuar en consecuencia. Por otro lado, si el valor nulo no debería estar allí: el primer nombre es nulo, entonces el desarrollador recibe la llamada telefónica proverbial de las 2 am.

También he visto código sobrecargado y complicado con verificaciones de nulo. Para mí esto significa una de dos cosas:
a) un error más arriba en el árbol de aplicaciones
b) diseño malo / incompleto

En el lado positivo – Nulo es probablemente una de las nociones más útiles para comprobar si algo está ausente, y los idiomas sin el concepto de nulo terminarán complicando demasiado las cosas cuando sea el momento de hacer la validación de datos. En este caso, si una nueva variable no se inicializa, dichos idiomas normalmente establecerán las variables en una cadena vacía, 0 o una colección vacía. Sin embargo, si una cadena vacía o 0 o una colección vacía son valores válidos para su aplicación, entonces tiene un problema.

A veces esto se evita inventando valores especiales / extraños para los campos para representar un estado no inicializado. Pero entonces, ¿qué sucede cuando el valor especial es ingresado por un usuario bien intencionado? Y no nos metamos en el caos que esto representará en las rutinas de validación de datos. Si el lenguaje apoyara el concepto nulo, todas las preocupaciones desaparecerían.

Los idiomas vectoriales a veces pueden salirse con la suya sin tener un nulo.

El vector vacío sirve como un nulo escrito en este caso.