¿Cómo manejan los idiomas con tipos Quizás en lugar de nulos condiciones de borde?

53

Eric Lippert hizo un punto muy interesante en su discusión de por qué C # usa un tipo en nulllugar 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?

Mason Wheeler
fuente
Supongo que si tal vez es parte del lenguaje, podría ser que se implementa internamente a través de un puntero nulo y es solo azúcar sintáctico. Pero no creo que ningún idioma lo haga así.
panzi
1
@panzi: Ceilán utiliza la escritura sensible al flujo para distinguir entre Type?(tal vez) y Type(no nulo)
Lukas Eder
1
@RobertHarvey ¿Ya no hay un botón de "buena pregunta" en Stack Exchange?
user253751
2
@panzi Esa es una optimización agradable y válida, pero no ayuda con este problema: cuando algo no es un Maybe T, no debe serlo Noney, por lo tanto, no puede inicializar su almacenamiento en el puntero nulo.
@immibis: ya lo empujé. Recibimos pocas buenas preguntas aquí; Pensé que este merecía un comentario.
Robert Harvey

Respuestas:

45

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:

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

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 de bar?

Sin embargo, este problema existe debido a la combinación de tres aspectos:

  • La ausencia de valores predeterminados como nullo inicialización garantizada para las variables miembro.
  • La diferencia entre declaración y asignación. Obligar a que las variables se asignen inmediatamente (por ejemplo, con una letdeclaración como se ve en los lenguajes funcionales) es fácil forzar la inicialización garantizada, pero restringe el idioma de otras maneras.
  • El sabor específico de los destructores como un método que recibe el tiempo de ejecución del lenguaje.

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:

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

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.

amon
fuente
También hay otra razón por la cual el finalizador tiene que lidiar con nulls: el orden de finalización no está garantizado, debido a la posibilidad de ciclos de referencia. Pero supongo que su FINALIZEdiseño también resuelve eso: si fooya se ha finalizado, su FINALIZEsección simplemente no se ejecutará.
svick
14

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 haga self.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

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

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
7

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 Nothingque 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 llamados Maybeque no son intercambiables con / directamente convertibles a regulares, no tipos anulables. Para utilizar un valor anulado envolviéndolo en a Maybe, 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:

¿podemos saber siempre que una referencia no anulable nunca se considera inválida bajo ninguna circunstancia?

Si. Inty Maybe Intson dos tipos completamente separados. Encontrar Nothingen una llanura Intsería comparable a encontrar la cadena "pez" en un Int32.

¿Qué pasa en el constructor de un objeto con un campo de tipo de referencia no anulable?

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.

¿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?

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 Maybetipo de datos para representar valores anulables. Quizás es un tipo de datos algabraicos definido de esta manera:

data Maybe a = Just a | Nothing

Para aquellos de ustedes que no están familiarizados con Haskell, lean esto como "A Maybees una Nothingo una Just a". Específicamente:

  • Maybees el constructor de tipos : puede considerarse (incorrectamente) como una clase genérica (donde aestá la variable de tipo). La analogía de C # es class Maybe<a>{}.
  • Justes un constructor de valores : es una función que toma un argumento de tipo ay devuelve un valor de tipo Maybe aque contiene el valor. Entonces el código x = Just 17es análogo a int? x = 17;.
  • Nothinges otro constructor de valores, pero no necesita argumentos y el Maybevalor devuelto no tiene otro valor que no sea "Nothing". x = Nothinges análogo a int? x = null;(suponiendo que limitamos nuestro ser aen Haskell Int, lo que se puede hacer escribiendo x = Nothing :: Maybe Int).

Ahora que los conceptos básicos del Maybetipo 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:

ReferenceType x;

No es posible en Haskell. No hay problemas con la inicialización de valores nullporque 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:

do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
    do thing 6
return thing 7

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 donotación de aspecto imperativo es simplemente azúcar sintáctica para pasar funciones anónimas al >>=operador. Todas las funciones toman la forma de:

<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression

Donde body-expressionpuede 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 Inta una Double? Utilice la fromIntegralfunción). Lo único que posiblemente tenga un valor no válido en tiempo de ejecución es usarlo Prelude.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):

data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar } 

( fooy barrealmente son funciones de acceso a campos anónimos aquí en lugar de campos reales, pero podemos ignorar este detalle).

El NotSoBrokenconstructor de datos es incapaz de tomar cualquier acción que no sea tomando una Fooy una Bar(que no son anulables) y haciendo una NotSoBrokende 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 Brokensiempre falla. No hay forma de romper el NotSoBrokenconstructor 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.

makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing

(la primera línea es una declaración de firma de tipo: makeNotSoBrokentoma a Fooy a Barcomo argumentos y produce a Maybe NotSoBroken).

El tipo de retorno debe ser Maybe NotSoBrokeny no simplemente NotSoBrokenporque le dijimos que evaluara Nothing, que es un constructor de valores para Maybe. 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 useNotSoBrokenque espera a NotSoBrokencomo argumento:

useNotSoBroken :: NotSoBroken -> Whatever

( useNotSoBrokenacepta a NotSoBrokencomo argumento y produce a Whatever).

Y úsalo así:

useNotSoBroken (makeNotSoBroken)

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: makeNotSoBrokendevuelve a Maybe NotSoBroken, pero useNotSoBrokenespera a NotSoBroken. Estos tipos no son intercambiables y el código no se compila.

Para evitar esto, podemos usar una casedeclaración para ramificar en función de la estructura del Maybevalor (usando una característica llamada coincidencia de patrones ):

case makeNotSoBroken of
    Nothing  -> --handle situation here
    (Just x) -> useNotSoBroken x

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:

  • Primero, makeNotSoBrokense evalúa, lo que garantiza que producirá un valor de tipo Maybe NotSoBroken.
  • La casedeclaración inspecciona la estructura de este valor.
  • Si el valor es Nothing, se evalúa el código "manejar situación aquí".
  • Si el valor coincide con un Justvalor, se ejecuta la otra rama. Observe cómo la cláusula de coincidencia identifica simultáneamente el valor como una Justconstrucción y vincula su NotSoBrokencampo interno a un nombre (en este caso x). xentonces puede usarse como el NotSoBrokenvalor 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.

Acercarse Oscuridad Peces
fuente
TL; DR debería estar en la parte superior :)
andrew.fox
@ andrew.fox Buen punto. Lo editaré
ApproachingDarknessFish
0

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 nullo Maybe::nonepara 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 nullvalor es prácticamente equivalente a Maybe::none. Puede asignar nullsolo a variables y miembros de objetos que en un nivel de tipo se declaran como nulables :

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

Esto no es diferente al siguiente fragmento:

Maybe<String> optionalString = getOptionalString();

En conclusión, no veo cómo la nulabilidad es de ninguna manera opuesta a los Maybetipos. Incluso sugeriría que C # se coló en su propio Maybetipo 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:

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );
Roland Tepp
fuente
2
¿Qué significa "el constructor completa o no completa"? En Java, por ejemplo, la inicialización del campo (no final) en el constructor no está protegida de la carrera de datos: ¿eso califica como completo o no?
mosquito
@gnat: ¿qué quiere decir con "En Java, por ejemplo, la inicialización del campo (no final) en el constructor no está protegida contra la carrera de datos". A menos que haga algo espectacularmente complejo que implique múltiples subprocesos, las posibilidades de condiciones de carrera dentro de un constructor son (o deberían ser) casi imposibles. No puede acceder a un campo de un objeto no construido excepto desde dentro del constructor de objetos. Y si la construcción falla, no tiene una referencia al objeto.
Roland Tepp
La gran diferencia entre nullcomo miembro implícito de cada tipo y Maybe<T>es que con Maybe<T>, también puede tener justo T, que no tiene ningún valor predeterminado.
svick
Al crear matrices, con frecuencia no será posible determinar valores útiles para todos los elementos sin tener que leer algunos, ni será posible verificar estáticamente que ningún elemento se lee sin que se haya calculado un valor útil para él. Lo mejor que puede hacer es inicializar los elementos de la matriz de tal manera que puedan reconocerse como inutilizables.
supercat
@svick: en C # (que era el lenguaje en cuestión del OP), nullno es un miembro implícito de todos los tipos. Para nullque sea un valor lebal, debe definir el tipo para que se pueda anular explícitamente, lo que hace que un T?(azúcar de sintaxis Nullable<T>) sea esencialmente equivalente a Maybe<T>.
Roland Tepp
-3

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 floatsconvierte en 0.0, se boolsvuelve 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 .

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}
ryancerium
fuente
2
la pregunta era sobre idiomas con tipos tal vez
mosquito
3
“Las referencias se vuelven nulas ”: toda la premisa de la pregunta es que no tenemos null, y la única forma de indicar la ausencia de un valor es usar un Maybetipo (también conocido como Option), 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 estar null.
amon
Si bien c ++ no tiene tipos tal vez de forma nativa explícitamente, cosas como std :: shared_ptr <T> están lo suficientemente cerca que creo que todavía es relevante que c ++ maneje el caso en el que la inicialización de variables puede ocurrir "fuera del alcance" del constructor, y de hecho, se requiere para los tipos de referencia (&), ya que no pueden ser nulos.
FryGuy