¿Por qué MSFT C# compila de manera diferente un "array to pointer decay" y "address of first element" fijo?


El compilador de c# de.NET (. NET 4.0) compila la instrucción fixed de una manera bastante peculiar.

Aquí hay un programa corto pero completo para mostrarle de lo que estoy hablando.

using System;

public static class FixedExample {

    public static void Main() {
        byte [] nonempty = new byte[1] {42};
        byte [] empty = new byte[0];

        Good(nonempty);
        Bad(nonempty);

        try {
            Good(empty);
        } catch (Exception e){
            Console.WriteLine(e.ToString());
            /* continue with next example */
        }
        Console.WriteLine();
        try {
            Bad(empty);
        } catch (Exception e){
            Console.WriteLine(e.ToString());
            /* continue with next example */
        }
     }

    public static void Good(byte[] buffer) {
        unsafe {
            fixed (byte * p = &buffer[0]) {
                Console.WriteLine(*p);
            }
        }
    }

    public static void Bad(byte[] buffer) {
        unsafe {
            fixed (byte * p = buffer) {
                Console.WriteLine(*p);
            }
        }
    }
}

Compilar con "csc.exe arregló el ejemplo.cs / unsafe/ o+ " si quieres seguir adelante.

Aquí está la IL generada para el método Good:

Bueno()

  .maxstack  2
  .locals init (uint8& pinned V_0)
  IL_0000:  ldarg.0
  IL_0001:  ldc.i4.0
  IL_0002:  ldelema    [mscorlib]System.Byte
  IL_0007:  stloc.0
  IL_0008:  ldloc.0
  IL_0009:  conv.i
  IL_000a:  ldind.u1
  IL_000b:  call       void [mscorlib]System.Console::WriteLine(int32)
  IL_0010:  ldc.i4.0
  IL_0011:  conv.u
  IL_0012:  stloc.0
  IL_0013:  ret

Aquí está la IL generada para el método Bad:

Malo()

  .locals init (uint8& pinned V_0, uint8[] V_1)
  IL_0000:  ldarg.0
  IL_0001:  dup
  IL_0002:  stloc.1
  IL_0003:  brfalse.s  IL_000a
  IL_0005:  ldloc.1
  IL_0006:  ldlen
  IL_0007:  conv.i4
  IL_0008:  brtrue.s   IL_000f
  IL_000a:  ldc.i4.0
  IL_000b:  conv.u
  IL_000c:  stloc.0
  IL_000d:  br.s       IL_0017
  IL_000f:  ldloc.1
  IL_0010:  ldc.i4.0
  IL_0011:  ldelema    [mscorlib]System.Byte
  IL_0016:  stloc.0
  IL_0017:  ldloc.0
  IL_0018:  conv.i
  IL_0019:  ldind.u1
  IL_001a:  call       void [mscorlib]System.Console::WriteLine(int32)
  IL_001f:  ldc.i4.0
  IL_0020:  conv.u
  IL_0021:  stloc.0
  IL_0022:  ret

Esto es lo que Good hace:

  1. Obtiene la dirección del buffer[0].
  2. Desreferenciar esa dirección.
  3. Llama a WriteLine con ese valor desreferenciado.

Esto es lo que hace 'Malo`:

  1. Si buffer es null, GOTO 3.
  2. If buffer.¡Largo != 0, GOTO 5.
  3. Almacene el valor 0 en la ranura local 0,
  4. GOTO 6.
  5. Obtiene la dirección del buffer[0].
  6. Deferencia que dirección (en la ranura local 0, que puede ser 0 o buffer ahora).
  7. Llama a WriteLine con ese valor desreferenciado.

Cuando buffer no es nulo y no está vacío, estas dos funciones hacen lo mismo. Observe que Bad simplemente salta a través de algunos aros antes de llegar a la llamada a la función WriteLine.

Cuando buffer es nulo, Good lanza un NullReferenceException en el declarador de puntero fijo (byte * p = &buffer[0]). Presumiblemente este es el comportamiento deseado para arreglar una matriz administrada, porque en general, cualquier operación dentro de una instrucción fija dependerá de la validez del objeto que se está fijando. De lo contrario, ¿por qué estaría ese código dentro del bloque fixed? Cuando Good se pasa una referencia nula, falla inmediatamente al inicio del bloque fixed, proporcionando un seguimiento de pila relevante e informativo. El desarrollador verá esto y se dará cuenta de que debe validar buffer antes de usarlo, o tal vez su lógica incorrectamente asignada null a buffer. De cualquier manera, claramente no es deseable introducir un bloque fixed con una matriz administrada null.

Bad maneja este caso de manera diferente, incluso indeseable. Se puede ver que Bad en realidad no lanzar una excepción hasta que p se eliminan las referencias. Lo hace de la manera indirecta de asignando null a la misma ranura local que contiene p, y luego lanzando la excepción cuando las instrucciones de bloque fixed desreferencian p.

Manejar null de esta manera tiene la ventaja de mantener modelo de objetos en C # consistente. Es decir, dentro del bloque fixed, p todavía se trata semánticamente como una especie de "puntero a una matriz administrada" que, cuando es nula, no causará problemas hasta (o a menos) que se desreferencie. La consistencia está muy bien, pero el problema es que p no es un puntero a una matriz administrada. Es un puntero al primer elemento de buffer, y cualquiera que haya escrito este código (Bad) interpretaría su significado semántico como tal. No puedes conseguir el tamaño de buffer desde p, y no se puede llamar p.ToString(), entonces, ¿por qué tratarlo como si fuera un objeto? En los casos donde buffer es null, hay claramente un error de codificación, y creo que sería mucho más útil si Bad lanzara una excepción al declarador de puntero fijo , en lugar de dentro del método.

Así que parece que Good maneja null mejor que Bad. ¿Y los búferes vacíos?

Cuando buffer tiene Longitud 0, Good lanza IndexOutOfRangeException al declarador de puntero fijo. Esa parece una forma completamente razonable de manejar el acceso a matrices fuera de los límites. Después de todo, el código &buffer[0] debe ser tratado de la misma manera que &(buffer[0]), que obviamente debería lanzar IndexOutOfRangeException.

Bad maneja este caso de manera diferente, y de nuevo indeseable. Así como sería el caso si buffer de null, cuando buffer.Length == 0, Bad no se lanza una excepción hasta que p se eliminan las referencias, y en ese momento se lanza NullReferenceException, no IndexOutOfRangeException! Si p nunca es desreferenciado, entonces el código ni siquiera lanza una excepción. De nuevo, parece que la idea aquí es dar p el significado semántico de "puntero a una matriz administrada". Una vez más, no creo que nadie que escriba este código pensaría en p de esa manera. El código sería mucho más útil si lanzara IndexOutOfRangeException en el declarador de puntero fijo , notificando así al desarrollador que la matriz pasada estaba vacía, y no null.

Parece que fixed(byte * p = buffer) debería haber sido compilado con el mismo código que fue fixed (byte * p = &buffer[0]). También observe que aunque buffer podría haber sido cualquier expresión arbitraria, su tipo (byte[]) es conocido en tiempo de compilación y por lo tanto el código en Good funcionaría para cualquier expresión arbitraria.

Editar

De hecho, observe que la implementación de Bad en realidad hace la comprobación de errores en buffer[0] twice . Lo hace explícitamente al principio del método, y luego lo hace de nuevo implícitamente en la instrucción ldelema.


Así que vemos que el Good y Bad son semánticamente diferentes. Bad es más largo, probablemente más lento, y ciertamente no nos da excepciones deseables cuando tenemos errores en nuestro código, e incluso falla mucho más tarde de lo que debería en algunos casos.

Para aquellos curiosos, la sección 18.6 de la especificación (C# 4.0) dice que el comportamiento está "definido por la implementación" en ambos fallos casos:

Un inicializador de puntero fijo puede ser uno de los siguientes:

• El token "&" seguido de una variable-referencia (§5.3.3) a una variable móvil (§18.3) de un tipo T no administrado, siempre que el tipo T* sea implícitamente convertible al tipo puntero dado en la instrucción fija. En este caso, el inicializador calcula la dirección de la variable dada, y se garantiza que la variable permanezca en una dirección fija durante la duración de la instrucción fija.

• Una expresión de un tipo array con elementos de un tipo T no administrado, siempre que el tipo T* sea implícitamente convertible al tipo puntero dado en la instrucción fixed. En este caso, el inicializador calcula la dirección del primer elemento de la matriz, y se garantiza que toda la matriz permanezca en una dirección fija durante la duración de la instrucción fixed. El comportamiento de la instrucción fixed está definido por la implementación si la expresión del array es null o si el array tiene cero elemento.

... otros casos ...

Último punto, la documentación de MSDN sugiere que los dos son "equivalentes":

// Las dos asignaciones siguientes son equivalentes...

Fijo (doble* p = arr) { /.../ }

Arreglado (doble * p = & arr[0]) { /.../ }

Si se supone que los dos son "equivalentes" , entonces ¿por qué usar una semántica de manejo de errores diferente para el ¿declaración anterior?

También parece que se puso un esfuerzo adicional en escribir las rutas de código generadas en Bad. El código compilado en Good funciona bien para todos los casos de fallo, y es el mismo que el código en Bad en los casos sin fallo. ¿Por qué implementar nuevas rutas de código en lugar de simplemente usar el código más simple generado para Good?

¿Por qué se implementa de esta manera?

Author: Michael Graczyk, 2012-08-04

2 answers

Es posible que haya notado que el código IL que incluyó implementa la especificación casi línea por línea. Eso incluye implementar explícitamente los dos casos de excepción enumerados en la especificación en el caso en que sean relevantes, y no incluir el código en el caso en que no lo sean.

Por supuesto, eso solo nos lleva a dos preguntas más que podríamos hacernos:

  • ¿Por qué la C# grupo lingüístico ¿elige escribir la especificación de esta manera?
  • ¿Por qué el equipo del compilador did eligió ese comportamiento específico definido por la implementación?

A menos que alguien de los equipos apropiados aparezca, realmente no podemos esperar responder a ninguna de esas preguntas por completo. Sin embargo, podemos tomar una puñalada en responder a la segunda tratando de seguir su razonamiento.

Recuerde que la especificación dice, en el caso de suministrar una matriz a una fixed-pointer-initializer , que

El comportamiento de la instrucción fixed está definido por la implementación si la expresión del array es null o si el array tiene cero elementos.

Dado que la implementación es libre de elegir hacer lo que quiera en este caso, podemos asumir que será cualquier comportamiento razonable que sea más fácil y barato para el equipo del compilador.

En este caso, lo que el equipo del compilador eligió hacer fue " lanzar un excepción en el punto donde su código hace algo mal". Considere lo que el código estaría haciendo si no estuviera dentro de un fixed-pointer-initializer y piense en qué más está sucediendo. En su ejemplo "Bueno", está tratando de tomar la dirección de un objeto que no existe: el primer elemento en un array null/empty. Eso no es algo que realmente se puede hacer, por lo que producirá una excepción. En su ejemplo "Malo", simplemente está asignando la dirección de un parámetro a un variable puntero; byte * p = null es una declaración perfectamente legítima. Es solo cuando intenta WriteLine(*p) que ocurre un error. Dado que al fixed-pointer-initializer se le permite hacer lo que quiera en este caso de excepción, lo más simple es permitir que la asignación suceda, por insignificante que sea.

Claramente, las dos declaraciones son no exactamente equivalentes. Podemos decir esto por el hecho de que el estándar los trata de manera diferente:

  • &arr[0] es: "El token " & "seguido de una variable-referencia", y así el compilador calcula la dirección de arr [0]
  • arr es: "Una expresión de tipo array", por lo que el compilador calcula la dirección del primer elemento del array, con la advertencia de que un array null o 0-length produce el comportamiento definido por la implementación que está viendo.

Los dos producen resultados equivalentes , siempre y cuando haya un elemento en el array, que es el punto que la documentación de MSDN está intentando para cruzar. Hacer preguntas sobre por qué el comportamiento explícitamente indefinido o definido por la implementación actúa de la manera en que lo hace realmente no va a ayudarlo a resolver ningún problema en particular, porque no puede confiar en que sea cierto en el futuro. (Habiendo dicho eso, por supuesto que sería curioso saber cuál fue el proceso de pensamiento, ya que obviamente no se puede "arreglar" un valor nulo en la memoria...)

 9
Author: Michael Edenfield,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-08-03 22:35:55

Así que vemos que lo Bueno y lo Malo son semánticamente diferentes. ¿Por qué?

Porque Bueno es el caso 1 y malo es el caso 2.

Good no asigna una "expresión de tipo array". Asigna "El token" y "seguido de una variable-referencia" por lo que es el caso 1. Bad asigna "Una expresión de tipo array" convirtiéndolo en el caso 2. Si esto es cierto, la documentación de MSDN es incorrecta.

En cualquier caso esto explica por qué el compilador de C# crea dos diferentes (y en el segundo casos especializados) patrones de código.

¿Por qué el caso 1 genera un código tan simple? Estoy especulando aquí: Tomar la dirección de un elemento array probablemente se compila de la misma manera que usar array[index] en una expresión ref. En el nivel CLR, ref los parámetros y expresiones son solo punteros administrados. También lo es la expresión &array[index]: Se compila a un puntero administrado que no está anclado sino "interior" (creo que este término proviene de Managed C++). El GC lo arregla automáticamente. Se comporta como un normal referencia de objeto.

Así que el caso 1 obtiene el tratamiento de puntero administrado habitual, mientras que el caso 2 obtiene un comportamiento especial, definido por la implementación (no indefinido).

Esto no responde a todas sus preguntas, pero al menos proporciona algunas razones para sus observaciones. Estoy esperando que Eric Lippert añada su respuesta como interno.

 1
Author: usr,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-08-03 22:10:55