Inicio > .NET Framework, C#, Cuestionario > C# 101: eventos y retro-llamadas

C# 101: eventos y retro-llamadas


Este tema, pienso yo, sobra un poco para todos aquellos que ya conocen C#. Sin embargo, viene dentro de los temas a tratar para la certificación 70-483, así que -nevertheless- lo incluiré, pero a guisa de cuestionario, para que sea más concreto.

¿Qué es un evento?

Un evento es un mecanismo mediante el cual una clase permite a objetos externos subscribirse a un delegado, pero sin poder invocarlo. El delegado, en cambio, pue…

Pera, ¿qué es un delegado?

¡Ah! Un delegado es un objeto que permite hacer retro-llamadas, también llamadas callba…

¡Pero no! ¿Qué es una retro-llamada?

Pues un puntero a una función. En fin, te decía que las retro-llama…

Carambas, ¿por qué no comienzas por el principio?

Vale, vale, ¡tranquis! En lenguajes como C y C++, existe un concepto mediante el cual uno puede declarar una variable que, en lugar de apuntar a una ubicación de memoria que contenga un valor, como un entero o una cadena de texto, contienen la ubicación en memoria de una función. Algo así:

void foo(int val) {

    printf("foo %d", val);

}

void goo(int val) {

    printf("goo %d", val);

}

 

Aquí tenemos un par de funciones de C que imprimen en pantalla el valor que les pasan como parámetro, precedido por una cadena de texto. La firma es un valor de retorno void, más un parámetro de tipo int. Un puntero a una función, bajo lo escrito anteriormente, sería así:

typedef (void *func)(int);

Y ahora podemos utilizar func como una variable, sólo que su tipo de dato es una función que regresa void y toma un parámetro int.

typedef (void *func)(int);


func f = foo;

f(5); // imprime "foo 5";

f = goo;

f(42); // imprime "goo 42";

Cuando una función tiene un parámetro de tipo puntero a función, y ésta es invocada bajo ciertas circunstancias desde dicha función, se dice que la función pasada como parámetro es una retro-llamada, ya que será invocada usualmente para indicar alguna notificación: errores, cambios de estado, o que todo salió bien. Por su nombre en inglés, una retro-llamada es un callback.

Ok, creo que ya entendí. Pero ¿para qué sirve un callback?

Bueno, un ejemplo es el que ya dimos. Una función necesita notificar algo a algún tercero, pero no quiere dejarlo fijo, sino que sea parametrizable. Piensa en una barra de progreso. La función Foo hace cálculo intensivo, y pide un callback para notificar el porcentaje de avance. Cada que avanza algo, Foo invoca al callback con el nuevo valor. Ya cada quién decide qué función pasar como callback: una que imprima algo en consola, una que guarde en log en texto plano, etc.

Pero también hay más. Piensa que usamos un array o lista de punteros a funciones. ¡Podríamos crear todo un sistema de notificaciones! De hecho, a través de los callbacks uno puede implementar el patrón de observador: en lugar de una función Update, invocamos a cada callback dentro del array.

Los callbacks son muy utilizados en todos lados en el API de Windows 32 y en general en el mundo de C. En C++, aunque también válidos y utilizados, se utiliza más el concepto de functor.

¿Qué es un functor de C++?

Un functor en C++ es una clase con el operador () sobrecargado, de tal suerte que puede comportarse como una función.

 

class print {

    public:
       std::string msg;
        int times;

        print() : msg("Hola mundo"), times(1) { }

        void execute() {

            for (int i = 0; i < times; i++)
                std::cout << msg << std::endl;

        }

        void operator()() {
            execute();
        }

        void operator()(int tms) {
            times = tms;
            execute();
        }

};

print p;
p.msg = "Hallo Welt!";
p.times = 1;
p(); // imprime Hallo Welt!
p.msg = "Auf wiedersehen Welt!";
p(2); // imprime dos veces Auf wiedersehen Welt!

A pesar de que p es un objeto, ¡se invoca como si fuera una función! Esto es muy útil porque pueden crearse clases de plantillas que permitan utilizar callbacks o functores, transparentemente.

Bueno: callbacks, functores, ¿qué tienen que ver con C#?

Ah, pues que los callbacks son objetos muy útiles. Sin embargo, los punteros son inherentemente inseguros, y ciertamente los punteros están casi prohibidos en C#. Sin embargo, los functores son una buena idea, y en .NET la retomaron, haciéndolos más robustos, más seguros y más fáciles de utilizar. Además, en C# se integraron al lenguaje: ¡les asignaron su propia sintaxis y palabras reservadas y toda la cosa!

A estos functores de .NET y C#, sin embargo, se les conoce con otro nombre: el de delegados.

Wow: ¿y qué es un delegado entonces?

Un delegado es una clase (que hereda de la clase base System.Delegate, aunque la herencia la hace el compilador de C# por ti) que define una firma de un método (tipo de retorno más firma de parámetros) mediante la cual define una colección de callbacks que pueden ser invocadas al unísono.

Usando terminología de .NET: un delegado es un tipo de dato que referencia uno o más métodos, y que se comporta exactamente como un método.

¿Y cómo se usa un delegado?

Para declarar un delegado, se indica el modificador, seguido por la palabra reservada delegate, el tipo de retorno del método, el nombre del delegado, y la lista de parámetros. Por ejemplo:

public int delegate Operacion(int a, int b);

Luego, para instanciar un delegado, pues es igual que con cualquier tipo, con la salvedad que en el constructor hay que pasar el nombre del método al que hace referencia.

int Suma(int a, int b) { return a + b; }
int Mult(int a, int b) { return a * b; }

Operacion op = new Operacion(Suma);
int val = Suma(5, 10); // val == 15
op = new Operacion(Mult);
val = op(5, 10); // val == 150

A partir de .NET 2.0, los delegados no necesitan la sintaxis "new Operación", sino que pueden asignar directamente al método:

Operacion op = Suma;
int val = Suma(5, 10); // val == 15
op = Mult;
val = op(5, 10); // val == 150

¿Dónde puedo usar un delegado?

¡Donde quieras, tío, donde quieras! O mejor dicho, donde puedas usar cualquier variable: a nivel de clase, de método, como parámetro de un método, etc. ¡Las posibilidades son amplísimas!

¿Y siempre debe saberse el tipo del delegado?

Mmm… técnicamente sí. Sin embargo, en C# existe el concepto de "delegado anónimo", o método anónimo. Esto es un delegado que no tiene un tipo explícito, sino que se define un cuerpo directamente. El tipo de dato existe, lo genera el compilador de C#, pero no es explícito para el programador. De ahí que se le denomine anónimo. Esto se hace así:

var suma = new delegate(int a, int b) { return a + b; }
sum(5, 10); // regresa 15

 

Necesitamos utilizar "var" porque no sabemos el tipo de retorno. Si te das cuenta no definimos el nombre del delegado, sólo su firma. En este caso, tenemos dos parámetros de tipo entero, y un parámetro de retorno de tipo entero también. Esto, aunque no es explícito, lo define el "return a + b". Es decir, el compilador deduce el tipo de retorno. Si no hubiera return, el tipo de retorno sería void.

Por cierto, ¡también puedes usar expresiones lambda! ;-)

var mult = (int a, int b) => a * b;

mult(5, 10); // regresa 150

¿Existen delegados en .NET Framework que pueda utilizar?

¡Por supuesto! Hay una gama importante de delegados ya existentes en la biblioteca base de clases. Mostrarlas aquí llevaría mucho tiempo, pero podemos ver algunos. Quizás el más conocido es EventHandler y su versión genérica, EventHandler<T>. Estos delegados se usan como estándares para manejadores de eventos, que veremos más adelante.

El delegado Action representa eso: una acción. Un método que regresa void y no tiene argumentos. Hay muchas variantes de Action, hasta con quince parámetros. Veamos un ejemplo sobre cómo usar este delegado.

void Execute<T>(IEnumerable<T> items, Action<T>  action)
{
    foreach (T item in items)
    {
        action(item);
    }
}

int[] nums = new int[] { 5, 10, 15, 20, 25 };
Action<int> action = delegate(int x) { Console.WriteLine("Número {0}", x); };

Execute(nums, action);

 

Similar a Action, tenemos a Func. La diferencia es que Func sí regresa un valor. Representa una función de cero a dieciséis parámetros posibles, según la versión que se incorpore.

IEnumerable<int> Transform(IEnumerable<int> input, Func<int, int> func)
{
    foreach (int n in input) {
        int value = func(n);
        yield return value;
    }
}

int[] nums = new int[] { 1, 2, 3, 4, 5 };
Func<int, int> sqr = x => x * x;
Func<int, int, int> cube = x => x * x * x,

var ret1 = Transform(nums, sqr); // ret1: { 1, 4, 9, 16, 25 }
var ret2 = Transform(nums, cube); // ret2: {1, 8, 27, 64, 125 }

En el ejemplo anterior, creamos un método que toma una colección y la transforma, según una función que pasemos como parámetro. Luego creamos dos delegados función: uno regresa el cuadrado de un número, y el otro el cubo.

Un tercer delegado es Predicate<T>. Este delegado representa una función que toma un parámetro de entrada y regresa verdadero o falso. El significado del predicado depende de su contexto, por supuesto. Pero básicamente es eso: regresar un valor booleano a partir de un parámetro. Por ejemplo, un predicado que separe números pares y nones podría lucir así:

int[] nums = new int[] { 1, 2, 3, 4, 5 };
Predicate<int> p = x => x % 2 == 0;

foreach (int n in nums)
{
    Console.WriteLine("Es par? {0}",  p(n));
}

De hecho, los predicados se usan mucho en colecciones. Por ejemplo, método Array.Exists determina si un array tiene un elemento que concuerde con el criterio pasado como parámetro.

int[] nums = new int[] { 1, 2, 3, 4, 5, 42 };
bool exists = Array.Exists(nums, x => x == 42); // regresa true

¡Ay jolines! Nota que pasamos una función lambda directamente como predicado… C# permite convertir lambdas al tipo de delegado que se requiera, siempre y cuando la firma concuerde.

Por último, también es importante Comparison<T>, muy usado para ordenar colecciones, representa una comparación entre dos objetos. El delegado tiene dos parámetros del mismo tipo, y regresa un entero. La convención es que si el delegado regresa un número menor a cero quiere decir que el primer parámetro es menor al segundo, si es mayor a cero entonces el primer parámetro es mayor al segundo, y si es cero, entonces son iguales.

List<int> lst = new List<int>(new int[] { 1, 2, 3, 4, 5 });
lst.Sort( (x, y) => x.CompareTo(y));
// lst = 1, 2, 3, 4, 5
lst.Sort( (x, y) => x.CompareTo(y) * -1);
// lst = 5, 4, 3, 2, 1

También podemos usar lambdas, por supuesto, como en el ejemplo anterior.

Entonces ahora sí: explícame qué es un evento

Decíamos que un evento es un mecanismo mediante el cual una clase permite a otros objetos enviar notificaciones (es decir, disparar un evento). La clase que tiene el evento se llama "publicador", y los objetos externos que recibirán la notificación, son los subscriptores.

Las subscripciones se hacen a través de delegados. Un delegado puede ser invocado por objetos externos, pero los eventos no: sólo pueden ser invocados dentro de la clase que los declara.

Un evento se declara utilizando el modificador de acceso, seguido de la palabra reservada "event", más el delegado que define el manejador de eventos (esto es, las funciones callback que serán llamadas cuando se dispare el evento), más el nombre del evento.

modificador event Delegado NombreEvento;

Por ejemplo:

public event EventHandler MyEvent;

Ahora veamos un ejemplo completo.

public delegate int Operación(int a, int b);

class Prueba {
    public event Operación RealizaOperacion;

    public void Invocar()
    {
        if (RealizaOperacion != null)
            RealizaOperacion(5, 10);
    }
}

private int Suma(int a, int b) { return a + b; }

Prueba p = new Prueba();
p.RealizaOperacion += new Operación(Suma);
p.RealizaOperacion += new delegate(int a, int b) { return a - b; }
p.RealizaOperacion += (int a, int b) => a * b;
p.Invocar();

Vemos varias cosas aquí. En primer lugar, declaramos un delegado. Luego, en la clase Prueba, declaramos un evento llamado RealizaOperación. El método Invocar revisa si el evento es nulo, es decir, si nadie se ha subscrito. Si no lo es, entonces invoca al delegado. Esto hará que se dispare el evento y llegue la notificación a todos los subscriptores del mismo.

Posteriormente, instanciamos un objeto prueba, y subscribimos tres delegados al evento: uno creando el delegado explícitamente, otro usando un método anónimo, y otro usando una expresión lambda. El siguiente paso invoca a todos los métodos vía el evento.

¿Y siempre hay que crear un delegado?

Todos los eventos requieren un delegado. Ahora bien, aunque puedes crear los tuyos propios, lo usual, la convención, es que los delegados de los eventos sean métodos que regresen void y tomen dos parámetros: un objeto que representa quien invoca el evento, y un objeto que herede de la clase System.EventArgs, y que represente los parámetros pasados a los delegados con información sobre un evento; si no hay argumentos, puede pasarse el objeto estático EventArgs.Empty.

El delegado más sencillo con lo anterior se llama EventHandler.

class Aviso 
{
    public event EventHandler Avisar;

    public void Invocar() {
        if (Avisar != null)
            Avisar(this, EventArgs.Empty);
    }
}

Aviso a = new Aviso();
a.Avisar = delegate(object sender, EventArgs args) { Console.WriteLine("Un aviso!"); };
a.Invocar(); // imprime "Un aviso!";

Si queremos pasar argumentos personalizados, necesitamos heredar de EventArgs y crear nuestro delegado que siga la convención.

 

class ContadorEventArgs : EventArgs
{
    public int Numero { get; private set; }

    public bool EsPar { get { return Numero % 2 == 0; } }

    public ContadorEventArgs(int num) {
        Numero = num;
    }
}

public delegate void ContadorHandler(object sender, ContadorEventArgs args);

class Contador
{
    public List<int> Numeros { get; private set; }

    public event ContadorHandler ParContado;

    public event ContadorHandler NonContado;

    public Contador() { Numeros = new List<int>(); }

    public void ContarPares()
    {
        foreach (int num in Numeros) {
            if (num % 2 == 0) {
                if (ParContado != null)
                    ParContado(this, new ContadorEventArgs(num));
            }
        }
   }

    public void ContarNones()
    {
        foreach (int num in Numeros) {
            if (num % 2 != 0) {
                if (NonContado != null)
                    NonContado(this, new ContadorEventArgs(num));
            }
        }
    }
}

Contador c = new Contador();
c.Numeros.AddRange(new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 });
c.ParContado = (sender, args) =>
    { Console.WriteLine("Es un par! {0}", args.Numero); };
c.NonContado = (sender, args) =>
    { Console.WriteLine("Oh, {0} es un non.", args.Numero); };
c.ContarPares();
c.ContarNones();

A partir de .NET 2.0, por cierto, existe el delegado EventHandler<T>, donde T es una clase que hereda de EventArgs. Así, ya no tenemos que crear nuestros delegados para los eventos, sino utilizar EventHandler:

class Contador
{
    … etc …

    public event EventHandler<ContadorEventArgts> ParContado;

    … etc …
}

Y a todo esto, ¿cómo me des-subscribo de un evento?

Con el operador -=. Más o menos así:

EventHandler<ContadorEventArgs> handler = delegate(object sender, ContadorEventArgs args) { … };

Contador c = new Contador();
c.ParContado += handler;
…
c.ParContado -= handler;

 

Easy peasy, ¿no?

Ya vimos cómo levantar un evento, pero… ¿hay alguna convención?

De hecho, sí la hay. La convención es que para un evento Evento, exista un método protegido y virtual, llamado OnEvento, que tome un derivado de EventArgs cuando aplique como parámetro.

class Button
{
    public event EventHandler Click;

    protected virtual void OnClick(EventArgs args)
    {
        if (Click != null)
            Click(this, args ?? EventArgs.Empty);
    }
}

Nota que OnClick es protegido y virtual. Esto, porque es preferido en clases derivadas que en lugar de subscribirse al evento de la clase base, sobrescriban el método. Es decir, es preferible hacer esto:

class CircleButton : Button
{
    protected override void OnClick(EventArgs args)
    {
        DrawCircleButton();
        base.OnClick();
    }
}

que esto:

class CircleButton : Button
{
    public CircleButton() : base()
    {
        this.Click += (s, a) => { DrawCircleButton(); };
    }
}

¿Y cómo se guarda internamente estas referencias a los delegados?

Los eventos en realidad guardan en memoria una referencia a cada delegado. Lo que pasa es que eso lo hace internamente el compilador. Si tienes cinco eventos en una clase, por ejemplo, se crearán cinco listas para cada evento, y cada una de estas almacenará delegados. Esto implica que si una clase tiene muchos eventos, su tamaño en memoria irá creciendo proporcionalmente. Si no tenemos cuidado, podemos vernos con una clase sumamente grande.

¿Hay forma de controlar este proceso?

De hecho, sí la hay. El concepto es el de "Event properties" o "eventos propiedades". Básicamente consiste en que tú decides cómo se almacenan los delegados, y puedes almacenarlo en una sola estructura (como, digamos, un Hashtable). De esta forma puedes reducir la huella de memoria de la clase.

Para hacer esto, primero hay que definir una colección de delegados que levanten los eventos, definir una clave para cada evento, definir los eventos propiedades, y usar la colección de delegados para implementar los accesores   de los eventos. Finalmente, se implementa un evento, pero como una propiedad: así como éstas tienen getters y setters, los eventos también pueden tener estructuras similares, llamadas adders y removers.

¿Cómo declaro event properties? ¿Tienes algún ejemplo?

Veamos un ejemplo. Tenemos esta clase.

class Button
{
    public event EventHandler Click;
    public event EventHandler Draw;
    public event EventHandler KeyPress;
}

Y queremos cambiarla para que use eventos propiedades. El primer paso es elegir una colección de delegados. Vamos a utilizar la clase EventHandlerList, del espacio de nombres System.ComponentModel. Esta clase está pensada para ser utilizada justamente en este escenario.

protected EventHandlerList _eventDelegates;

Button() {
    _eventDelegates = new EventHandlerList();
}

Luego, tenemos que elegir una llave para cada evento. Esta llave puede ser cualquier valor: un número, una cadena de texto. O puede ser un vil object vacío. Dado que es una llave por evento, bien podemos hacerlos estáticos y readonly.

private static readonly object _clickEventKey;
private static readonly object _drawEventKey;
private static readonly object _keyPressEventKey;

static Button() {
    _clickEventKey = new object();
    _drawEventKey = new object();
    _keyPressEventKey = new object();
}

Perfecto. Siguiente paso, añadimos el evento. Cambiamos los eventos que teníamos. Esto es nuevo, así que pon atención:

public event EventHandler Click
{
    add { _eventDelegates.AddHandler(_clickEventKey, value); }
    remove { _eventDelegates.RemoveHandler(_clickEventKey, value); }
}

¡Sasquatch! Al evento le hemos añadido un par de adders y removers, que es el símil de los getters y setters de las propiedades. Por ello se llaman eventos propiedades. Las palabras add y remove son contextuales. El add se invoca cuando se subscribe un evento, el remove cuando se elimina, por supuesto. El value, como en las propiedades, es contextual, y tiene el valor del manejador de evento (y por tanto es de tipo EventHandler en el ejemplo anterior).

Lo que hacemos en este caso es invocar a AddHandler y RemoveHandler del EventHandlerList que creamos hace rato, respectivamente. Ambos métodos se encargan de revisar referencias válidas y que existan llaves y así, hacen todo por ti. Si utilizaras alguna otra colección, como un Hashtable, tendrías que hacerlo por tu cuenta. De ahí que sea mejor usar esta colección. Por cierto, ambos métodos reciben como segundo parámetro un objeto de tipo Delegate. Esta clase es la base de todos los delegados, así que no importa qué delegado tenga tu evento como referencia. Es decir, no tienes que usar EventHandler, puede ser cualquier delegado.

Esto fue para el evento Click. Algo similar debe hacerse para los otros dos eventos. Y luego viene el último paso: el invocar el evento. Quedamos que por convención usamos un método protegido, virtual, y cuyo nombre se forma prefijando un On al nombre del evento. Este método lo que debe hacer es , por supuesto, obtener una referencia al delegado correcto del evento, a partir de la llave del mismo (en este caso, a partir de _clickEventKey). Y después simplemente se invoca como se invocaría cualquier delegado. Sólo recuerda hacer la conversión de Delegate al tipo de tu delegado (en este caso, todos son de tipo EventHandler).

protected virtual void OnClick(EventArgs args)
{
    EventHandler handler = _eventDelegates[_clickEventKey] as EventHandler;
    if (handler != null)
        handler(this, args ?? EventArgs.Empty);
}

¿Cómo veis? Aquí está la clase final.

class Button : IDisposable
{
    protected EventHandlerList _eventDelegates;
    private static readonly object _clickEventKey;
    private static readonly object _drawEventKey;
    private static readonly object _keyPressEventKey;

    Button() {
        _eventDelegates = new EventHandlerList();
    }

    static Button() {
        _clickEventKey = new object();
        _drawEventKey = new object();
        _keyPressEventKey = new object();
    }

    public event EventHandler Click
    {
        add { _eventDelegates.AddHandler(_clickEventKey, value); }
        remove { _eventDelegates.RemoveHandler(_clickEventKey, value); }
    }

    public event EventHandler Draw
    {
        add { _eventDelegates.AddHandler(_drawEventKey, value); }
        remove { _eventDelegates.RemoveHandler(_drawEventKey, value); }
    }

    public event EventHandler KeyPressed
    {
        add { _eventDelegates.AddHandler(_keyPressedEventKey, value); }
        remove { _eventDelegates.RemoveHandler(_keyPressedEventKey, value); }
    }

    protected virtual void OnClick(EventArgs args)
    {
        var handler = _eventDelegates[_clickEventKey] as EventHandler;
        if (handler != null)
            handler(this, args ?? EventArgs.Empty);
    }

    protected virtual void OnDraw(EventArgs args)
    {
        var handler = _eventDelegates[_drawEventKey] as EventHandler;
        if (handler != null)
            handler(this, args ?? EventArgs.Empty);
    }

    protected virtual void OnKeyPressed(EventArgs args)
    {
        var handler = _eventDelegates[_keyPressedEventKey] as EventHandler;
        if (handler != null)
            handler(this, args ?? EventArgs.Empty);
    }

    public void Dispose() {
        _eventDelegates.Dispose();
    }
}

¿Por qué añadiste ese Dispose al final?

Ahm… porque EventHandlerList imlementa IDisposable. Es una parte esencial. Recuerden que cuando subscribimos un evento, éstos obtienen una referencia a la clase donde se subscriben (o mejor dicho, a sus métodos). Al final hay que liberar esas referencias, porque si no la clase no liberará recursos (por referencias circulares). Entonces hay que mandar llamar al Dispose de _eventDelegates. Y para ello, ponemos un Dispose propio e implementamos IDisposable. Digo, ya entrados en gastos…

¿Algo más?

Pues no. Ya conoces lo básico de manejar eventos en .NET, conoces los delegados y callbacks y cómo emplearlos. A partir de .NET 3.0, con la llegada de Windows Presentation Foundation, Silverlight y ahora .NET para Metro (.NET for Windows Store Apps), han surgido otra modalidad de eventos, llamados eventos ruteados o RoutedEvents. Pero eso es harina de otro costal.

Categorías:.NET Framework, C#, Cuestionario Etiquetas: , ,
  1. Aún no hay comentarios.
  1. No trackbacks yet.

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s