Eric Lippert hizo un punto muy interesante en su discusión de por qué C # usa un tipo en null
lugar de un Maybe<T>
tipo :
La consistencia del sistema de tipos es importante; ¿podemos saber siempre que una referencia no anulable nunca se considera inválida bajo ninguna circunstancia? ¿Qué pasa en el constructor de un objeto con un campo de tipo de referencia no anulable? ¿Qué pasa con el finalizador de dicho objeto, donde el objeto se finaliza porque el código que se suponía que debía completar la referencia arrojó una excepción? Un sistema de tipos que le miente sobre sus garantías es peligroso.
Eso fue un poco revelador. Los conceptos involucrados me interesan, y he jugado un poco con compiladores y sistemas de tipos, pero nunca pensé en ese escenario. ¿Cómo los lenguajes que tienen un tipo Quizás en lugar de un nulo manejan casos extremos como la inicialización y la recuperación de errores, en los que una referencia no nula supuestamente garantizada no está, de hecho, en un estado válido?
fuente
Type?
(tal vez) yType
(no nulo)Maybe T
, no debe serloNone
y, por lo tanto, no puede inicializar su almacenamiento en el puntero nulo.Respuestas:
Esa cita apunta a un problema que ocurre si la declaración y la asignación de identificadores (aquí: miembros de instancia) están separadas entre sí. Como un bosquejo rápido de pseudocódigo:
El escenario ahora es que durante la construcción de una instancia, se generará un error, por lo que la construcción se cancelará antes de que la instancia se haya construido por completo. Este lenguaje ofrece un método destructor que se ejecutará antes de que se desasigne la memoria, por ejemplo, para liberar manualmente recursos que no sean de memoria. También debe ejecutarse en objetos parcialmente construidos, ya que los recursos administrados manualmente podrían haber sido asignados antes de que se cancelara la construcción.
Con nulos, el destructor podría probar si una variable se había asignado como
if (foo != null) foo.cleanup()
. Sin valores nulos, el objeto ahora está en un estado indefinido: ¿cuál es el valor debar
?Sin embargo, este problema existe debido a la combinación de tres aspectos:
null
o inicialización garantizada para las variables miembro.let
declaración como se ve en los lenguajes funcionales) es fácil forzar la inicialización garantizada, pero restringe el idioma de otras maneras.Es fácil elegir otro diseño que no presente estos problemas, por ejemplo, combinando siempre la declaración con la asignación y haciendo que el lenguaje ofrezca múltiples bloques finalizadores en lugar de un solo método de finalización:
Por lo tanto, no existe un problema con la ausencia de nulo, sino con la combinación de un conjunto de otras características con ausencia de nulo.
La pregunta interesante ahora es por qué C # eligió un diseño pero no el otro. Aquí, el contexto de la cita enumera muchos otros argumentos para un nulo en el lenguaje C #, que se puede resumir principalmente como "familiaridad y compatibilidad", y esas son buenas razones.
fuente
null
s: el orden de finalización no está garantizado, debido a la posibilidad de ciclos de referencia. Pero supongo que suFINALIZE
diseño también resuelve eso: sifoo
ya se ha finalizado, suFINALIZE
sección simplemente no se ejecutará.Las mismas formas en que garantiza cualquier otro dato se encuentran en un estado válido.
Se puede estructurar la semántica y controlar el flujo de manera que no se pueda tener una variable / campo de algún tipo sin crear un valor completo para ella. En lugar de crear un objeto y dejar que un constructor asigne valores "iniciales" a sus campos, solo puede crear un objeto especificando valores para todos sus campos a la vez. En lugar de declarar una variable y luego asignar un valor inicial, solo puede introducir una variable con una inicialización.
Por ejemplo, en Rust se crea un objeto de tipo struct en
Point { x: 1, y: 2 }
lugar de escribir un constructor que lo hagaself.x = 1; self.y = 2;
. Por supuesto, esto puede chocar con el estilo de lenguaje que tiene en mente.Otro enfoque complementario es utilizar el análisis de vida para evitar el acceso al almacenamiento antes de su inicialización. Esto permite declarar una variable sin inicializarla inmediatamente, siempre y cuando se haya asignado antes de la primera lectura. También puede detectar algunos casos relacionados con fallas como
Técnicamente, también podría definir una inicialización predeterminada arbitraria para los objetos, por ejemplo, poner a cero todos los campos numéricos, crear matrices vacías para los campos de la matriz, etc. pero esto es bastante arbitrario, menos eficiente que otras opciones y puede enmascarar errores.
fuente
Así es como lo hace Haskell: (no es exactamente un contador de las declaraciones de Lippert ya que Haskell no es un lenguaje orientado a objetos).
ADVERTENCIA: larga respuesta sin aliento de un fanático serio de Haskell por delante.
TL; DR
Este ejemplo ilustra exactamente cuán diferente es Haskell de C #. En lugar de delegar la logística de la construcción de la estructura a un constructor, debe manejarse en el código circundante. No hay forma de
Nothing
que aparezca un valor de valor nulo (o en Haskell) donde esperamos un valor no nulo porque los valores nulos solo pueden ocurrir dentro de tipos especiales de envoltorios llamadosMaybe
que no son intercambiables con / directamente convertibles a regulares, no tipos anulables. Para utilizar un valor anulado envolviéndolo en aMaybe
, primero debemos extraer el valor utilizando la coincidencia de patrones, lo que nos obliga a desviar el flujo de control hacia una rama donde sabemos con certeza que tenemos un valor no nulo.Por lo tanto:
Si.
Int
yMaybe Int
son dos tipos completamente separados. EncontrarNothing
en una llanuraInt
sería comparable a encontrar la cadena "pez" en unInt32
.No es un problema: los constructores de valores en Haskell no pueden hacer nada más que tomar los valores que se les dan y juntarlos. Toda la lógica de inicialización tiene lugar antes de que se llame al constructor.
No hay finalizadores en Haskell, por lo que realmente no puedo abordar esto. Sin embargo, mi primera respuesta sigue en pie.
Respuesta completa :
Haskell no tiene nulo y utiliza el
Maybe
tipo de datos para representar valores anulables. Quizás es un tipo de datos algabraicos definido de esta manera:Para aquellos de ustedes que no están familiarizados con Haskell, lean esto como "A
Maybe
es unaNothing
o unaJust a
". Específicamente:Maybe
es el constructor de tipos : puede considerarse (incorrectamente) como una clase genérica (dondea
está la variable de tipo). La analogía de C # esclass Maybe<a>{}
.Just
es un constructor de valores : es una función que toma un argumento de tipoa
y devuelve un valor de tipoMaybe a
que contiene el valor. Entonces el códigox = Just 17
es análogo aint? x = 17;
.Nothing
es otro constructor de valores, pero no necesita argumentos y elMaybe
valor devuelto no tiene otro valor que no sea "Nothing".x = Nothing
es análogo aint? x = null;
(suponiendo que limitamos nuestro sera
en HaskellInt
, lo que se puede hacer escribiendox = Nothing :: Maybe Int
).Ahora que los conceptos básicos del
Maybe
tipo están fuera del camino, ¿cómo evita Haskell los problemas discutidos en la pregunta del OP?Bueno, Haskell es realmente diferente de la mayoría de los idiomas discutidos hasta ahora, así que comenzaré explicando algunos principios básicos del lenguaje.
En primer lugar, en Haskell, todo es inmutable . Todo. Los nombres se refieren a valores, no a ubicaciones de memoria donde se pueden almacenar valores (esto solo es una fuente enorme de eliminación de errores). A diferencia de C #, donde la declaración y la asignación de variables son dos operaciones separadas, en Haskell los valores se crean definiendo su valor (p
x = 15
. Ej .y = "quux"
,z = Nothing
), Que nunca puede cambiar. Por lo tanto, código como:No es posible en Haskell. No hay problemas con la inicialización de valores
null
porque todo debe inicializarse explícitamente en un valor para que exista.En segundo lugar, Haskell no es un lenguaje orientado a objetos : es un lenguaje puramente funcional , por lo que no hay objetos en el sentido estricto de la palabra. En cambio, simplemente hay funciones (constructores de valores) que toman sus argumentos y devuelven una estructura amalgamada.
A continuación, no hay absolutamente ningún código de estilo imperativo. Con esto, quiero decir que la mayoría de los idiomas siguen un patrón similar a este:
El comportamiento del programa se expresa como una serie de instrucciones. En los lenguajes orientados a objetos, las declaraciones de clase y función también juegan un papel importante en el flujo del programa, pero es esencial, la "carne" de la ejecución de un programa toma la forma de una serie de instrucciones para ser ejecutadas.
En Haskell, esto no es posible. En cambio, el flujo del programa se dicta completamente mediante el encadenamiento de funciones. Incluso la
do
notación de aspecto imperativo es simplemente azúcar sintáctica para pasar funciones anónimas al>>=
operador. Todas las funciones toman la forma de:Donde
body-expression
puede haber cualquier cosa que se evalúe como un valor. Obviamente, hay más funciones de sintaxis disponibles, pero el punto principal es la ausencia total de secuencias de declaraciones.Por último, y probablemente lo más importante, el sistema de tipos de Haskell es increíblemente estricto. Si tuviera que resumir la filosofía de diseño central del sistema de tipos de Haskell, diría: "Haga que salgan mal tantas cosas como sea posible en el momento de la compilación para que lo menos posible salga mal en tiempo de ejecución". No hay conversiones implícitas en absoluto (¿desea promover una
Int
a unaDouble
? Utilice lafromIntegral
función). Lo único que posiblemente tenga un valor no válido en tiempo de ejecución es usarloPrelude.undefined
(que aparentemente solo tiene que estar allí y es imposible de eliminar ).Con todo esto en mente, veamos el ejemplo "roto" de amon e intentemos volver a expresar este código en Haskell. Primero, la declaración de datos (usando la sintaxis de registro para los campos con nombre):
(
foo
ybar
realmente son funciones de acceso a campos anónimos aquí en lugar de campos reales, pero podemos ignorar este detalle).El
NotSoBroken
constructor de datos es incapaz de tomar cualquier acción que no sea tomando unaFoo
y unaBar
(que no son anulables) y haciendo unaNotSoBroken
de ellas. No hay lugar para poner código imperativo o incluso asignar manualmente los campos. Toda la lógica de inicialización debe tener lugar en otro lugar, muy probablemente en una función de fábrica dedicada.En el ejemplo, la construcción de
Broken
siempre falla. No hay forma de romper elNotSoBroken
constructor de valores de manera similar (simplemente no hay ningún lugar para escribir el código), pero podemos crear una función de fábrica que sea igualmente defectuosa.(la primera línea es una declaración de firma de tipo:
makeNotSoBroken
toma aFoo
y aBar
como argumentos y produce aMaybe NotSoBroken
).El tipo de retorno debe ser
Maybe NotSoBroken
y no simplementeNotSoBroken
porque le dijimos que evaluaraNothing
, que es un constructor de valores paraMaybe
. Los tipos simplemente no se alinearían si escribiéramos algo diferente.Además de ser absolutamente inútil, esta función ni siquiera cumple su propósito real, como veremos cuando intentemos usarla. Creemos una función llamada
useNotSoBroken
que espera aNotSoBroken
como argumento:(
useNotSoBroken
acepta aNotSoBroken
como argumento y produce aWhatever
).Y úsalo así:
En la mayoría de los idiomas, este tipo de comportamiento puede causar una excepción de puntero nulo. En Haskell, los tipos no coinciden:
makeNotSoBroken
devuelve aMaybe NotSoBroken
, perouseNotSoBroken
espera aNotSoBroken
. Estos tipos no son intercambiables y el código no se compila.Para evitar esto, podemos usar una
case
declaración para ramificar en función de la estructura delMaybe
valor (usando una característica llamada coincidencia de patrones ):Obviamente, este fragmento debe colocarse dentro de algún contexto para compilar realmente, pero demuestra los conceptos básicos de cómo Haskell maneja los valores nulables. Aquí hay una explicación paso a paso del código anterior:
makeNotSoBroken
se evalúa, lo que garantiza que producirá un valor de tipoMaybe NotSoBroken
.case
declaración inspecciona la estructura de este valor.Nothing
, se evalúa el código "manejar situación aquí".Just
valor, se ejecuta la otra rama. Observe cómo la cláusula de coincidencia identifica simultáneamente el valor como unaJust
construcción y vincula suNotSoBroken
campo interno a un nombre (en este casox
).x
entonces puede usarse como elNotSoBroken
valor normal que es.Por lo tanto, la coincidencia de patrones proporciona una función poderosa para hacer cumplir la seguridad de tipos, ya que la estructura del objeto está inseparablemente unida a la ramificación del control.
Espero que esta haya sido una explicación comprensible. Si no tiene sentido, ¡entra en Learn You A Haskell For Great Good! , uno de los mejores tutoriales de idiomas en línea que he leído. Espero que veas la misma belleza en este idioma que yo.
fuente
Creo que su cita es un argumento de hombre de paja.
Los lenguajes modernos de hoy (incluido C #) le garantizan que el constructor se completa por completo o no.
Si hay una excepción en el constructor y el objeto se deja parcialmente sin inicializar, tener
null
oMaybe::none
para el estado sin inicializar no hace una diferencia real en el código del destructor.Solo tendrá que lidiar con eso de cualquier manera. Cuando hay recursos externos para administrar, debe administrarlos explícitamente de cualquier manera. Los idiomas y las bibliotecas pueden ayudar, pero tendrá que pensar un poco en esto.
Por cierto: en C #, el
null
valor es prácticamente equivalente aMaybe::none
. Puede asignarnull
solo a variables y miembros de objetos que en un nivel de tipo se declaran como nulables :Esto no es diferente al siguiente fragmento:
En conclusión, no veo cómo la nulabilidad es de ninguna manera opuesta a los
Maybe
tipos. Incluso sugeriría que C # se coló en su propioMaybe
tipo y lo llamóNullable<T>
.Con los métodos de extensión, es incluso fácil obtener la limpieza de Nullable para seguir el patrón monádico:
fuente
null
como miembro implícito de cada tipo yMaybe<T>
es que conMaybe<T>
, también puede tener justoT
, que no tiene ningún valor predeterminado.null
no es un miembro implícito de todos los tipos. Paranull
que sea un valor lebal, debe definir el tipo para que se pueda anular explícitamente, lo que hace que unT?
(azúcar de sintaxisNullable<T>
) sea esencialmente equivalente aMaybe<T>
.C ++ lo hace al tener acceso al inicializador que ocurre antes del cuerpo del constructor. C # ejecuta el inicializador predeterminado antes del cuerpo del constructor, asigna aproximadamente 0 a todo, se
floats
convierte en 0.0, sebools
vuelve falso, las referencias se vuelven nulas, etc. En C ++ puede hacer que se ejecute un inicializador diferente para garantizar que un tipo de referencia no nulo nunca sea nulo .fuente
null
, y la única forma de indicar la ausencia de un valor es usar unMaybe
tipo (también conocido comoOption
), que AFAIK C ++ no tiene en el biblioteca estándar La ausencia de nulos nos permite garantizar que un campo siempre será válido como propiedad del sistema de tipos . Esta es una garantía más sólida que asegurarse manualmente de que no exista una ruta de código donde una variable aún pueda estarnull
.