C # Generics - Como evitar o método redundante?


28

Vamos supor que eu tenha duas classes parecidas com esta (o primeiro bloco de código e o problema geral estão relacionados ao C #):

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

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

Essas classes não podem ser alteradas de forma alguma (elas fazem parte de uma montagem de terceiros). Portanto, não posso fazê-los implementar a mesma interface ou herdar a mesma classe que conteria IntProperty.

Eu quero aplicar alguma lógica na IntPropertypropriedade de ambas as classes, e em C ++ eu poderia usar uma classe de modelo para fazer isso facilmente:

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

};

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

E então eu poderia fazer algo assim:

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

Dessa forma, eu poderia criar uma única classe de fábrica genérica que funcionaria para a ClassA e a ClassB.

No entanto, em C #, tenho que escrever duas classes com duas wherecláusulas diferentes, mesmo que o código para a lógica seja exatamente o mesmo:

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;
    }
}

Eu sei que se eu quero ter classes diferentes na wherecláusula, elas precisam estar relacionadas, ou seja, herdar a mesma classe, se eu quiser aplicar o mesmo código a elas no sentido que descrevi acima. É apenas muito chato ter dois métodos completamente idênticos. Também não quero usar a reflexão por causa dos problemas de desempenho.

Alguém pode sugerir alguma abordagem em que isso possa ser escrito de uma maneira mais elegante?


3
Por que você está usando genéricos para isso em primeiro lugar? Não há nada de genérico nessas duas funções.
11276 Luaan

11
@Luaan Este é um exemplo simplificado de uma variação do padrão abstrato de fábrica. Imagine que existem dezenas de classes que herdam ClassA ou ClassB, e que ClassA e ClassB são classes abstratas. As classes herdadas não carregam informações adicionais e precisam ser instanciadas. Em vez de escrever uma fábrica para cada um deles, optei por usar genéricos.
Vladimir Stokic

6
Bem, você pode usar a reflexão ou a dinâmica se estiver confiante de que eles não serão quebrados em versões futuras.
Casey #

Esta é realmente a minha maior reclamação sobre genéricos é que ela não pode fazer isso.
Joshua

11
@ Josué, penso nisso mais como um problema com interfaces que não suportam "digitação de pato".
Ian

Respostas:


49

Inclua uma interface de proxy (às vezes chamada de adaptador , ocasionalmente com diferenças sutis), implemente LogicToBeAppliedem termos de proxy e adicione uma maneira de construir uma instância desse proxy a partir de duas lambdas: uma para a propriedade get e outra para o 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;
    }
}

Agora, sempre que você precisar passar um IProxy, mas possuir uma instância de classes de terceiros, basta passar algumas 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

Além disso, você pode escrever ajudantes simples para construir instâncias do LamdaProxy a partir de instâncias de A ou B. Eles podem até ser métodos de extensão para fornecer um estilo "fluente":

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);
    }
}

E agora a construção de proxies fica assim:

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

Quanto à sua fábrica, verificaria se você pode refatorá-la para um método "principal" de fábrica que aceite um IProxy e execute toda a lógica nele e outros métodos que apenas passam new A().Proxied()ou 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;
    }
}

Não há como fazer o equivalente ao seu código C ++ em C # porque os modelos C ++ dependem da digitação estrutural . Desde que duas classes tenham o mesmo nome e assinatura de método, em C ++, você pode chamar esse método genericamente em ambas. C # possui digitação nominal - o nome de uma classe ou interface faz parte de seu tipo. Portanto, as classes Ae Bnão podem ser tratadas da mesma forma em nenhuma capacidade, a menos que um relacionamento explícito "é um" seja definido por herança ou implementação de interface.

Se o clichê da implementação desses métodos por classe for muito grande, você pode escrever uma função que pega um objeto e cria um reflexivoLambdaProxy procurando um nome de propriedade 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));
     }
}

Isso falha abismalmente quando dados objetos do tipo incorreto; A reflexão introduz inerentemente a possibilidade de falhas que o sistema do tipo C # não pode impedir. Felizmente, você pode evitar a reflexão até que a carga de manutenção dos ajudantes se torne muito grande, porque você não precisa modificar a interface IProxy ou a implementação LambdaProxy para adicionar o açúcar reflexivo.

Parte da razão pela qual isso funciona é que LambdaProxyé "maximamente genérico"; ele pode adaptar qualquer valor que implemente o "espírito" do contrato IProxy, porque a implementação do LambdaProxy é completamente definida pelas funções getter e setter fornecidas. Até funciona se as classes tiverem nomes diferentes para a propriedade, ou tipos diferentes que sejam representáveis ​​de forma sensata e segura como ints, ou se houver alguma maneira de mapear o conceito que Propertydeve representar para outros recursos da classe. A indireção fornecida pelas funções oferece a máxima flexibilidade.


Uma abordagem muito interessante e definitivamente pode ser usada para chamar as funções, mas pode ser usada para a fábrica, onde eu realmente preciso criar objetos de ClassA e ClassB?
Vladimir Stokic 3/11

@VladimirStokic Ver edições, eu expandiu um pouco sobre isso
Jack

certamente este método ainda requer que você mapear explicitamente a propriedade para cada tipo com a possibilidade adicional de um erro de execução se a sua função de mapeamento é buggy
Ewan

Como alternativa ao ReflectiveProxier, você poderia criar um proxy usando a dynamicpalavra - chave? Parece-me que você teria os mesmos problemas fundamentais (ou seja, erros que são detectados apenas no tempo de execução), mas a sintaxe e a manutenção seriam muito mais simples.
Bobson

11
@ Jack - justo o suficiente. Eu adicionei minha própria resposta de demonstração. É um recurso muito útil, em certas circunstâncias raras (como esta).
Bobson

12

Aqui está um resumo de como usar adaptadores sem herdar de A e / ou B, com a possibilidade de usá-los para objetos A e 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
}

Normalmente, eu preferiria esse tipo de adaptador de objeto a proxies de classe; eles evitam problemas feios com os quais você pode se deparar com herança. Por exemplo, esta solução funcionará mesmo que A e B sejam classes seladas.


Por que new int Property? você não está escondendo nada.
pinkfloydx33

@ pinkfloydx33: apenas um erro de digitação, mudou, obrigado.
Doc Brown

9

Você pode se adaptar ClassAe ClassBatravés de uma interface comum. Dessa forma, seu código LogicAToBeAppliedpermanece o mesmo. Não é muito diferente do que você tem.

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 { }

11
+1 usando o padrão do adaptador é a solução tradicional de POO aqui. É mais de um adaptador de um proxy desde que adaptar os A, Btipos de uma interface comum. A grande vantagem é que não precisamos duplicar a lógica comum. A desvantagem é que a lógica agora instancia o wrapper / proxy em vez do tipo real.
amon

5
O problema com esta solução é que você não pode simplesmente pegar dois objetos do tipo A e B, convertê-los de alguma forma em AProxy e BProxy e aplicar LogicToBeApplied a eles. Esse problema pode ser resolvido usando agregação em vez de herança (ou seja, implemente os objetos proxy não derivando de A e B, mas fazendo referência a objetos A e B). Novamente, um exemplo de como o uso incorreto da herança causa problemas.
Doc Brown #

@DocBrown Como isso aconteceria neste caso em particular?
Vladimir Stokic 3/11

11
@ Jack: esse tipo de solução faz sentido quando LogicToBeAppliedtem uma certa complexidade e não deve ser repetido em dois locais da base de código sob nenhuma circunstância. Em seguida, o código adicional padrão é geralmente insignificante.
Doc Brown

11
@ Jack Onde está a redundância? As duas classes não têm uma interface comum. Você cria invólucros que não têm uma interface comum. Você usa essa interface comum para implementar sua lógica. Não é que a mesma redundância não exista no código C ++ - apenas oculta um pouco da geração de código. Se você se sentir tão fortemente sobre coisas que parecem iguais, mesmo que não sejam iguais, sempre poderá usar T4s ou algum outro sistema de modelos.
11896 Luaan

8

A versão C ++ funciona apenas porque seus modelos usam "digitação estática de pato" - qualquer coisa é compilada desde que o tipo forneça os nomes corretos. É mais como um sistema macro. O sistema genérico de C # e outros idiomas funciona de maneira muito diferente.

As respostas de devnull e Doc Brown mostram como o padrão do adaptador pode ser usado para manter seu algoritmo geral e ainda operar em tipos arbitrários ... com algumas restrições. Notavelmente, agora você está criando um tipo diferente do que realmente deseja.

Com um pouco de truque, é possível usar exatamente o tipo pretendido sem nenhuma alteração. No entanto, agora precisamos extrair todas as interações com o tipo de destino em uma interface separada. Aqui, essas interações são construção e atribuição de propriedades:

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

Em uma interpretação OOP, este seria um exemplo do padrão de estratégia , embora misturado com genéricos.

Em seguida, podemos reescrever sua lógica para usar essas interações:

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

As definições de interação seriam parecidas com:

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

A grande desvantagem dessa abordagem é que o programador precisa escrever e passar uma instância de interação ao chamar a lógica. Isso é bastante semelhante às soluções baseadas em padrão de adaptador, mas é um pouco mais geral.

Na minha experiência, este é o mais próximo possível das funções de modelo em outros idiomas. Técnicas semelhantes são usadas em Haskell, Scala, Go e Rust para implementar interfaces fora de uma definição de tipo. No entanto, nessas linguagens, o compilador intervém e seleciona implicitamente a instância de interação correta, para que você não veja o argumento extra. Isso também é semelhante aos métodos de extensão do C #, mas não está restrito aos métodos estáticos.


Abordagem interessante. Não é a primeira opção, mas acho que pode ter algumas vantagens ao escrever uma estrutura ou algo assim.
Doc Brown

8

Se você realmente quer ter cuidado com o vento, pode usar "dinâmico" para fazer com que o compilador cuide de toda a sujeira da reflexão. Isso resultará em um erro de tempo de execução se você passar um objeto para SetSomeProperty que não possui uma propriedade chamada 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

As outras respostas identificam corretamente o problema e fornecem soluções viáveis. O C # não suporta (geralmente) a "digitação de pato" ("Se andar como um pato ..."), portanto não há como forçar ClassAe ClassBser intercambiável se eles não foram projetados dessa maneira.

No entanto, se você já está disposto a aceitar o risco de uma falha de tempo de execução, há uma resposta mais fácil do que usar o Reflection.

C # tem a dynamicpalavra - chave que é perfeita para situações como essa. Ele diz ao compilador "Eu não saberei que tipo é esse até o tempo de execução (e talvez nem mesmo assim), então permita-me fazer qualquer coisa a ele".

Usando isso, você pode criar exatamente a função que deseja:

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.
    }
}

Observe o uso da staticpalavra - chave também. Isso permite que você use isso como:

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

Não há implicações no desempenho geral do uso dynamic, da maneira como ocorre o sucesso único (e a complexidade adicional) do uso do Reflection. A primeira vez que seu código atingir a chamada dinâmica com um tipo específico terá uma pequena sobrecarga , mas as chamadas repetidas serão tão rápidas quanto o código padrão. No entanto, você irá obter um RuntimeBinderExceptionse você tentar passar em algo que não tem essa propriedade, e não há nenhuma boa maneira de verificar que antes do tempo. Você pode lidar especificamente com esse erro de uma maneira útil.


Isso pode ser lento, mas geralmente o código lento não é um problema.
Ian

@Ian - Bom ponto. Eu adicionei um pouco mais sobre o desempenho. Na verdade, não é tão ruim quanto você imagina, desde que você reutilize as mesmas classes nos mesmos locais.
Bobson

Lembre-se de que os modelos C ++ nem sequer têm a sobrecarga dos métodos virtuais!
Ian

2

Você pode usar a reflexão para extrair a propriedade pelo nome.

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, você corre o risco de um erro de tempo de execução com este método. É o que o C # está tentando impedir você de fazer.

Eu li em algum lugar que uma versão futura do C # permitirá que você passe objetos como uma interface que eles não herdam, mas correspondem. O que também resolveria seu problema.

(Vou tentar desenterrar o artigo)

Outro método, embora não tenha certeza de que poupa algum código, seria subclassificar A e B e também herdar uma Interface com IntProperty.

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

public class A2 : A, IIntProp {}

public class B2 : B, IIntProp {}

Possibilidade de erro de tempo de execução e problemas de desempenho são as razões pelas quais eu não queria ir com a reflexão. No entanto, estou muito interessado em ler o artigo que você mencionou na sua resposta. Ansioso para lê-lo.
Vladimir Stokic

11
certamente você corre o mesmo risco com sua solução c ++?
Ewan

4
@Ewan nenhuma, C ++ controlos para o membro em tempo de compilação
Caleth

Reflexão significa problemas de otimização e (muito mais importante) erros de tempo de execução difíceis de depurar. Herança e uma interface comum significam declarar uma subclasse antecipadamente para cada uma dessas classes (nenhuma maneira de criar uma anonimamente no local) e não funciona se elas não usarem o mesmo nome de propriedade todas as vezes.
Jack

11
@Jack existem desvantagens, mas considere que a reflexão é usada extensivamente em mapeadores, serializadores, estruturas de injeção de dependência etc. e que o objetivo é fazê-lo com o mínimo de duplicação de código
Ewan

0

Eu só queria usar implicit operatorconversões junto com a abordagem delegado / lambda da resposta de Jack. Ae Bsão como assumidos:

// A and B are mutable reference types

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

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

É fácil obter uma sintaxe agradável com conversões implícitas definidas pelo usuário (não são necessários métodos de extensão ou 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(), };
}

Ilustração 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;
  }
}

O Initializemétodo mostra como você pode trabalhar Adaptersem se preocupar se é um Aou umB ou algo mais. As invocações do Initializemétodo mostram que não precisamos de nenhum molde (visível) ou .AsProxy()ou similar para tratar concreto Aou Bcomo um Adapter.

Considere se você deseja lançar um ArgumentNullException nas conversões definidas pelo usuário se o argumento passado for uma referência nula ou não.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.