C # Genéricos: ¿cómo evitar el método redundante?


28

Supongamos que tengo dos clases que se ven así (el primer bloque de código y el problema general están relacionados con C #):

class A 
{
    public int IntProperty { get; set; }
}

class B 
{
    public int IntProperty { get; set; }
}

Estas clases no se pueden cambiar de ninguna manera (son parte de una asamblea de terceros). Por lo tanto, no puedo hacer que implementen la misma interfaz o hereden la misma clase que luego contendría IntProperty.

Quiero aplicar algo de lógica en la IntPropertypropiedad de ambas clases, y en C ++ podría usar una clase de plantilla para hacerlo con bastante facilidad:

template <class T>
class LogicToBeApplied
{
    public:
        void T CreateElement();

};

template <class T>
T LogicToBeApplied<T>::CreateElement()
{
    T retVal;
    retVal.IntProperty = 50;
    return retVal;
}

Y luego podría hacer algo como esto:

LogicToBeApplied<ClassA> classALogic;
LogicToBeApplied<ClassB> classBLogic;
ClassA classAElement = classALogic.CreateElement();
ClassB classBElement = classBLogic.CreateElement();   

De esa manera podría crear una única clase de fábrica genérica que funcionaría tanto para ClassA como para ClassB.

Sin embargo, en C #, tengo que escribir dos clases con dos wherecláusulas diferentes a pesar de que el código para la lógica es exactamente el mismo:

public class LogicAToBeApplied<T> where T : ClassA, new()
{
    public T CreateElement()
    {
        T retVal = new T();
        retVal.IntProperty = 50;
        return retVal;
    }
}

public class LogicBToBeApplied<T> where T : ClassB, new()
{
    public T CreateElement()
    {
        T retVal = new T();
        retVal.IntProperty = 50;
        return retVal;
    }
}

Sé que si quiero tener diferentes clases en la wherecláusula, deben estar relacionadas, es decir, heredar la misma clase, si quiero aplicarles el mismo código en el sentido que describí anteriormente. Es solo que es muy molesto tener dos métodos completamente idénticos. Tampoco quiero usar la reflexión debido a los problemas de rendimiento.

¿Alguien puede sugerir algún enfoque en el que esto se pueda escribir de una manera más elegante?


3
¿Por qué estás usando genéricos para esto en primer lugar? No hay nada genérico en esas dos funciones.
Luaan el

1
@Luaan Este es un ejemplo simplificado de una variación del patrón abstracto de fábrica. Imagine que hay docenas de clases que heredan ClassA o ClassB, y que ClassA y ClassB son clases abstractas. Las clases heredadas no contienen información adicional y deben ser instanciadas. En lugar de escribir una fábrica para cada uno de ellos, opté por usar genéricos.
Vladimir Stokic

66
Bueno, podría usar la reflexión o la dinámica si está seguro de que no lo romperán en futuras versiones.
Casey el

Esta es realmente mi mayor queja sobre los genéricos es que no puede hacerlo.
Joshua

1
@ Joshua, lo considero más como un problema con las interfaces que no admiten "tipear patos".
Ian

Respuestas:


49

Agregue una interfaz proxy (a veces llamada adaptador , ocasionalmente con diferencias sutiles), implemente LogicToBeApplieden términos del proxy, luego agregue una forma de construir una instancia de este proxy a partir de dos lambdas: una para la propiedad get y otra para el conjunto.

interface IProxy
{
    int Property { get; set; }
}
class LambdaProxy : IProxy
{
    private Function<int> getFunction;
    private Action<int> setFunction;
    int Property
    {
        get { return getFunction(); }
        set { setFunction(value); }
    }
    public LambdaProxy(Function<int> getter, Action<int> setter)
    {
        getFunction = getter;
        setFunction = setter;
    }
}

Ahora, cada vez que necesite pasar un IProxy pero tener una instancia de las clases de terceros, puede pasar algunas lambdas:

A a = new A();
B b = new B();
IProxy proxyA = new LambdaProxy(() => a.Property, (val) => a.Property = val);
IProxy proxyB = new LambdaProxy(() => b.Property, (val) => b.Property = val);
proxyA.Property = 12; // mutates the proxied `a` as well

Además, puede escribir ayudantes simples para construir instancias de LamdaProxy a partir de instancias de A o B. Incluso pueden ser métodos de extensión para darle un estilo "fluido":

public static class ProxyExtension
{
    public static IProxy Proxied(this A a)
    {
      return new LambdaProxy(() => a.Property, (val) => a.Property = val);
    }

    public static IProxy Proxied(this B b)
    {
      return new LambdaProxy(() => b.Property, (val) => b.Property = val);
    }
}

Y ahora la construcción de proxies se ve así:

IProxy proxyA = new A().Proxied();
IProxy proxyB = new B().Proxied();

En cuanto a su fábrica, vería si puede refactorizarlo en un método de fábrica "principal" que acepte un IProxy y realice toda la lógica en él y otros métodos que simplemente pasan new A().Proxied()o new B().Proxied():

public class LogicToBeApplied
{
    public A CreateA() {
      A a = new A();
      InitializeProxy(a.Proxied());
      return a; // or maybe return the proxy if you'd rather use that
    }

    public B CreateB() {
      B b = new B();
      InitializeProxy(b.Proxied());
      return b;
    }

    private void InitializeProxy(IProxy proxy)
    {
        proxy.IntProperty = 50;
    }
}

No hay forma de hacer el equivalente de su código de C ++ en C # porque las plantillas de C ++ se basan en la tipificación estructural . Siempre que dos clases tengan el mismo nombre y firma del método, en C ++ puede llamar a ese método genéricamente en ambos. C # tiene una tipificación nominal : el nombre de una clase o interfaz es parte de su tipo. Por lo tanto, las clases Ay Bno se pueden tratar de la misma manera, a menos que se defina una relación explícita "es una" mediante herencia o implementación de interfaz.

Si la base de implementación de estos métodos por clase es demasiado, puede escribir una función que tome un objeto y construya reflexivamenteLambdaProxy buscando un nombre de propiedad específico:

public class ReflectiveProxier 
{
    public object proxyReflectively(object proxied)
    {
        PropertyInfo prop = proxied.GetType().GetProperty("Property");
        return new LambdaProxy(
            () => prop.GetValue(proxied),
            (val) => prop.SetValue(proxied, val));
     }
}

Esto falla abismalmente cuando se le dan objetos de tipo incorrecto; La reflexión introduce inherentemente la posibilidad de fallas que el sistema tipo C # no puede evitar. Afortunadamente, puede evitar la reflexión hasta que la carga de mantenimiento de los ayudantes sea demasiado grande porque no es necesario que modifique la interfaz IProxy o la implementación de LambdaProxy para agregar el azúcar reflectante.

Parte de la razón por la que esto funciona es que LambdaProxyes "máximamente genérico"; puede adaptar cualquier valor que implemente el "espíritu" del contrato IProxy porque la implementación de LambdaProxy está completamente definida por las funciones getter y setter dadas. Incluso funciona si las clases tienen diferentes nombres para la propiedad, o diferentes tipos que son representables de manera sensata y segura como ints, o si hay alguna forma de mapear el concepto que Propertyse supone que representa a otras características de la clase. La indirección proporcionada por las funciones le brinda la máxima flexibilidad.


Un enfoque muy interesante, y definitivamente se puede usar para llamar a las funciones, pero ¿se puede usar para la fábrica, donde realmente necesito crear objetos de Clase A y Clase B?
Vladimir Stokic

@VladimirStokic Ver ediciones, me he expandido un poco en esto
Jack

seguramente este método aún requiere que mapees explícitamente la propiedad para cada tipo con la posibilidad adicional de un error de tiempo de ejecución si tu función de mapeo tiene errores
Ewan

Como alternativa al ReflectiveProxier, ¿podría construir un proxy usando la dynamicpalabra clave? Me parece que tendría los mismos problemas fundamentales (es decir, errores que solo se detectan en tiempo de ejecución), pero la sintaxis y la facilidad de mantenimiento serían mucho más simples.
Bobson el

1
@ Jack - Bastante justo. Añadí mi propia respuesta demoing ella. Es una característica muy útil, en ciertas circunstancias raras (como esta).
Bobson el

12

Aquí hay un resumen de cómo usar adaptadores sin heredar de A y / o B, con la posibilidad de usarlos para objetos A y B existentes:

interface IAdapter
{
    int Property { get; set; }
}

class LogicToBeApplied<T> where T : IAdapter, new()
{
    public T Create()
    {
        var ret = new T();
        ret.Property = 50;
        return ret;
    }
}

class AAdapter : IAdapter
{
    A _a;

    public AAdapter()  // use this if you want to have the "logic" part create new objects
    {
        _a=new A();
    }

    public AAdapter(A a) // if you need an adapter for an existing object afterwards
    {
       _a=a;
    }

    public int Property
    {
        get { return _a.Property; }
        set { _a.Property = value; }
    }

    public A {get{return _a; } } // to provide access for non-generic code
}

class BAdapter 
{
     // analogously
}

Por lo general, preferiría este tipo de adaptador de objetos en lugar de proxies de clase, evitan problemas feos con los que puede encontrarse con la herencia. Por ejemplo, esta solución funcionará incluso si A y B son clases selladas.


¿Por qué new int Property? No estás sombreando nada.
pinkfloydx33

@ pinkfloydx33: solo un error tipográfico, lo cambió, gracias.
Doc Brown el

9

Podrías adaptarte ClassAy a ClassBtravés de una interfaz común. De esta manera, su código LogicAToBeAppliedpermanece igual. Sin embargo, no es muy diferente de lo que tienes.

class A
{
    public int Property { get; set; }
}
class B
{
    public int Property { get; set; }
}

interface IAdapter
{
    int Property { get; set; }
}

class LogicToBeApplied<T> where T : IAdapter, new()
{
    public T Create()
    {
        var ret = new T();
        ret.Property = 50;
        return ret;
    }
}

class AAdapter : A, IAdapter { }

class BAdapter : B, IAdapter { }

1
+1 usando el patrón de adaptador es la solución OOP tradicional aquí. Es más de un adaptador de un proxy, ya que adaptamos los A, Btipos de una interfaz común. La gran ventaja es que no tenemos que duplicar la lógica común. La desventaja es que la lógica ahora instancia el contenedor / proxy en lugar del tipo real.
amon

55
El problema con esta solución es que no puede simplemente tomar dos objetos de tipo A y B, convertirlos de alguna manera en AProxy y BProxy, y luego aplicarles LogicToBeApplied. Este problema se puede resolver utilizando la agregación en lugar de la herencia (por ejemplo, implementar los objetos proxy no derivando de A y B, sino tomando una referencia a los objetos A y B). Una vez más, un ejemplo de cómo el uso incorrecto de la herencia causa problemas.
Doc Brown

@DocBrown ¿Cómo iría eso en este caso particular?
Vladimir Stokic

1
@Jack: este tipo de solución tiene sentido cuando LogicToBeAppliedtiene una cierta complejidad y no debe repetirse en dos lugares en la base del código bajo ninguna circunstancia. Entonces, el código adicional repetitivo suele ser insignificante.
Doc Brown

1
@ Jack ¿Dónde está la redundancia? Las dos clases no tienen una interfaz común. Se crea envolturas que no tienen una interfaz común. Utiliza esa interfaz común para implementar tu lógica. No es que la misma redundancia no exista en el código C ++, solo está oculta detrás de un poco de generación de código. Si cree firmemente en cosas que se ven iguales, aunque no sean iguales, siempre puede usar T4 o algún otro sistema de plantillas.
Luaan el

8

La versión C ++ solo funciona porque sus plantillas usan "tipeo de pato estático", todo se compila siempre que el tipo proporcione los nombres correctos. Es más como un sistema macro. El sistema genérico de C # y otros lenguajes funciona de manera muy diferente.

Las respuestas de devnull y Doc Brown muestran cómo se puede usar el patrón del adaptador para mantener su algoritmo en general, y aún operar en tipos arbitrarios ... con un par de restricciones. En particular, ahora está creando un tipo diferente del que realmente desea.

Con un poco de truco, es posible usar exactamente el tipo deseado sin ningún cambio. Sin embargo, ahora necesitamos extraer todas las interacciones con el tipo de destino en una interfaz separada. Aquí, estas interacciones son construcción y asignación de propiedades:

interface IInteractions<T> {
  T Instantiate();
  void AssignProperty(T target, int value);
}

En una interpretación de OOP, este sería un ejemplo del patrón de estrategia , aunque mezclado con genéricos.

Luego podemos reescribir su lógica para usar estas interacciones:

public class LogicBToBeApplied<T>
{
    public T CreateElement(IInteractions<T> interactions)
    {
        T retVal = interactions.Instantiate();
        interactions.AssignProperty(retVal, 50);
        return retVal;
    }
}

Las definiciones de interacción se verían así:

class Interactions_ClassA : IInteractions<ClassA> {
  public override ClassA Instantiate() { return new ClassA(); }
  public override void AssignProperty(ClassA target, int value) { target.IntProperty = value; }
}

La gran desventaja de este enfoque es que el programador necesita escribir y pasar una instancia de interacción al llamar a la lógica. Esto es bastante similar a las soluciones basadas en patrones de adaptador, pero es un poco más general.

En mi experiencia, esto es lo más cerca que puede llegar a las funciones de plantilla en otros idiomas. Se utilizan técnicas similares en Haskell, Scala, Go y Rust para implementar interfaces fuera de una definición de tipo. Sin embargo, en estos idiomas el compilador interviene y selecciona la instancia de interacción correcta implícitamente para que no vea el argumento adicional. Esto también es similar a los métodos de extensión de C #, pero no se limita a los métodos estáticos.


Enfoque interesante No es la que sería mi primera opción, pero supongo que puede tener algunas ventajas al escribir un marco o algo así.
Doc Brown el

8

Si realmente quieres arrojar precaución al viento, puedes usar "dinámico" para hacer que el compilador se encargue de toda la maldad del reflejo por ti. Esto generará un error de tiempo de ejecución si pasa un objeto a SetSomeProperty que no tiene una propiedad llamada SomeProperty.

using System;

namespace ConsoleApplication3
{
    class A
    {
        public int SomeProperty { get; set; }
    }

    class B
    {
        public int SomeProperty { get; set; }
    }

    class Program
    {
        static void Main(string[] args)
        {
            var a = new A();
            var b = new B();

            SetSomeProperty(a, 7);
            SetSomeProperty(b, 12);

            Console.WriteLine($"a.SomeProperty = {a.SomeProperty}, b.SomeProperty = {b.SomeProperty}");
        }

        static void SetSomeProperty(dynamic obj, int value)
        {
            obj.SomeProperty = value;
        }
    }
}

4

Las otras respuestas identifican correctamente el problema y proporcionan soluciones viables. C # no admite (en general) el "tipeo de pato" ("Si camina como un pato ..."), por lo que no hay forma de forzarlo ClassAy ClassBser intercambiable si no se diseñaron de esa manera.

Sin embargo, si ya está dispuesto a aceptar el riesgo de una falla de tiempo de ejecución, entonces hay una respuesta más fácil que usar Reflection.

C # tiene la dynamicpalabra clave que es perfecta para situaciones como esta. Le dice al compilador "No sabré de qué tipo es hasta el tiempo de ejecución (y tal vez ni siquiera en ese momento), así que permíteme hacer algo ".

Con eso, puede construir exactamente la función que desea:

public class LogicToBeApplied<T> where T : new()
{
    public static T CreateElement()
    {
        dynamic retVal = new T(); // This doesn't care what type T is.
        retVal.IntProperty = 50;  // This will fail at runtime if there is no "IntProperty" 
                                  // or it doesn't accept an int.
        return retVal;            // Once again, we don't care what it is.
    }
}

Tenga en cuenta el uso de la staticpalabra clave también. Eso te permite usar esto como:

A classAElement = LogicToBeApplied<A>.CreateElement();
B classBElement = LogicToBeApplied<B>.CreateElement();

No hay implicaciones de rendimiento de imagen general del uso dynamic, la forma en que existe el éxito único (y la complejidad adicional) de usar Reflection. La primera vez que su código llegue a la llamada dinámica con un tipo específico tendrá una pequeña cantidad de sobrecarga , pero las llamadas repetidas serán tan rápidas como el código estándar. Sin embargo, usted va a obtener un RuntimeBinderExceptionsi intenta pasar en algo que no tiene esa propiedad, y no hay buena manera de comprobar que antes de tiempo. Es posible que desee manejar específicamente ese error de una manera útil.


Esto puede ser lento, pero a menudo el código lento no es un problema.
Ian

@ Ian - Buen punto. Agregué un poco más sobre el rendimiento. En realidad, no es tan malo como pensarías, siempre que estés reutilizando las mismas clases en los mismos lugares.
Bobson el

¡Recuerde que en las plantillas C ++ ni siquiera tienen la sobrecarga de los métodos virtuales!
Ian

2

Puede usar la reflexión para extraer la propiedad por su nombre.

public class logic 
{
    public object getNew<T>() where T : new()
    {
        T ret = new T();
        try
        {
            var property = typeof(T).GetProperty("IntProperty");
            if (property != null && property.PropertyType == typeof(int))
            {
                property.SetValue(ret, 50);
            }
        }
        catch (AmbiguousMatchException)
        {
            //hmm..
        }
        return ret;
    }
}

Obviamente corre el riesgo de un error de tiempo de ejecución con este método. Que es lo que C # está tratando de evitar que hagas.

Leí en alguna parte que una versión futura de C # le permitirá pasar objetos como una interfaz que no heredan pero coinciden. Lo que también resolvería tu problema.

(Intentaré desenterrar el artículo)

Otro método, aunque no estoy seguro de que te guarde ningún código, sería subclasificar tanto A como B y también heredar una interfaz con IntProperty.

public interface IIntProp {
    public int IntProperty {get, set}
}

public class A2 : A, IIntProp {}

public class B2 : B, IIntProp {}

La posibilidad de errores de tiempo de ejecución y problemas de rendimiento son las razones por las que no quería ir con la reflexión. Sin embargo, estoy muy interesado en leer el artículo que mencionó en su respuesta. Estoy ansioso por leerlo.
Vladimir Stokic

1
¿Seguramente corre el mismo riesgo con su solución C ++?
Ewan

44
@Ewan no, c ++ comprueba el miembro en tiempo de compilación
Caleth

La reflexión significa problemas de optimización y (mucho más importante) errores de tiempo de ejecución difíciles de depurar. La herencia y una interfaz común significa declarar una subclase con anticipación para cada una de estas clases (no hay forma de hacerla anónimamente en el acto) y no funciona si no usan el mismo nombre de propiedad cada vez.
Jack

1
@Jack hay inconvenientes, pero considere que la reflexión se usa ampliamente en los mapeadores, serializadores, marcos de inyección de dependencias, etc. y que el objetivo es hacerlo con la menor cantidad de duplicación de código
Ewan

0

Solo quería usar implicit operatorconversiones junto con el enfoque delegado / lambda de la respuesta de Jack. Ay Bson como se supone:

// A and B are mutable reference types

class A
{
  public int IntProperty { get; set; }
}

class B
{
  public int IntProperty { get; set; }
}

Entonces es fácil obtener una buena sintaxis con conversiones implícitas definidas por el usuario (no se necesitan métodos de extensión o similares):

// Adapter is an immutable type. However, the delegate instances have a captured reference to an A or a B (closure semantics)
struct Adapter
{
  readonly Func<int> getter;
  readonly Action<int> setter;

  Adapter(Func<int> getter, Action<int> setter)
  {
    this.getter = getter;
    this.setter = setter;
  }

  public int IntProperty
  {
    get { return getter(); }
    set { setter(value); }
  }

  public static implicit operator Adapter(A a) => new Adapter(() => a.IntProperty, x => a.IntProperty = x);
  public static implicit operator Adapter(B b) => new Adapter(() => b.IntProperty, x => b.IntProperty = x);

  public A CloneToA() => new A { IntProperty = getter(), };
  public B CloneToB() => new B { IntProperty = getter(), };
}

Ilustración de uso:

class LogicToBeApplied
{
  public static A CreateA()
  {
    var a = new A();
    Initialize(a);
    return a;
  }
  public static B CreateB()
  {
    var b = new B();
    Initialize(b);
    return b;
  }

  static void Initialize(Adapter a)
  {
    a.IntProperty = 50;
  }
}

El Initializemétodo muestra cómo puede trabajar Adaptersin preocuparse por si es una Ao una Bo alguna otra cosa. Las invocaciones del Initializemétodo muestran que no necesitamos ningún molde (visible) .AsProxy()o similar para tratar el concreto Ao Bcomo un Adapter.

Considere si desea incluir un ArgumentNullExceptionen las conversiones definidas por el usuario si el argumento pasado es una referencia nula o no.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.