Generici C # - Come evitare il metodo ridondante?


28

Supponiamo di avere due classi che assomigliano a questo (il primo blocco di codice e il problema generale sono correlati a C #):

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

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

Queste classi non possono essere modificate in alcun modo (fanno parte di un assembly di terze parti). Pertanto, non riesco a farli implementare la stessa interfaccia o ereditare la stessa classe che conterrebbe quindi IntProperty.

Voglio applicare una logica sulla IntPropertyproprietà di entrambe le classi, e in C ++ potrei usare una classe template per farlo abbastanza 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 poi potrei fare qualcosa del genere:

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

In questo modo ho potuto creare un'unica classe di fabbrica generica che avrebbe funzionato sia per ClassA che per ClassB.

Tuttavia, in C #, devo scrivere due classi con due diverse whereclausole anche se il codice per la logica è esattamente lo stesso:

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

So che se voglio avere diverse classi nella whereclausola, devono essere correlate, cioè ereditare la stessa classe, se voglio applicare loro lo stesso codice nel senso che ho descritto sopra. È solo che è molto fastidioso avere due metodi completamente identici. Inoltre, non voglio usare la riflessione a causa di problemi di prestazioni.

Qualcuno può suggerire un approccio in cui questo può essere scritto in un modo più elegante?


3
Perché stai usando generici per questo in primo luogo? Non c'è nulla di generico in queste due funzioni.
Luaan,

1
@Luaan Questo è un esempio semplificato di una variazione del modello astratto di fabbrica. Immagina che ci siano dozzine di classi che ereditano ClassA o ClassB e che ClassA e ClassB sono classi astratte. Le classi ereditate non portano ulteriori informazioni e devono essere istanziate. Invece di scrivere una fabbrica per ognuna di esse, ho scelto di usare i generici.
Vladimir Stokic,

6
Bene, potresti usare la riflessione o la dinamica se sei sicuro che non lo spezzeranno nelle versioni future.
Casey,

Questa è in realtà la mia più grande lamentela sui generici è che non può farlo.
Joshua,

1
@Joshua, lo considero più come un problema con le interfacce che non supportano la "tipizzazione anatra".
Ian,

Risposte:


49

Aggiungi un'interfaccia proxy (a volte chiamata un adattatore , a volte con sottili differenze), implementala LogicToBeAppliedin termini di proxy, quindi aggiungi un modo per costruire un'istanza di questo proxy da due lambda: uno per la proprietà get e uno per il set.

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

Ora, ogni volta che devi passare un IProxy ma hai un'istanza delle classi di terze parti, puoi semplicemente passare alcuni lambda:

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

Inoltre, puoi scrivere semplici helper per costruire istanze LamdaProxy da istanze di A o B. Possono anche essere metodi di estensione per darti uno stile "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 ora la costruzione di proxy è simile a questa:

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

Per quanto riguarda la tua fabbrica, vedrei se riesci a trasformarlo in un metodo di fabbrica "principale" che accetta un IProxy ed esegue tutta la logica su di esso e altri metodi che passano 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;
    }
}

Non c'è modo di fare l'equivalente del codice C ++ in C # perché i modelli C ++ si basano sulla tipizzazione strutturale . Finché due classi hanno lo stesso nome e la stessa firma del metodo, in C ++ è possibile chiamare quel metodo genericamente su entrambi. C # ha una digitazione nominale : il nome di una classe o interfaccia fa parte del suo tipo. Pertanto, le classi Ae Bnon possono essere trattate allo stesso modo in alcun modo a meno che una relazione esplicita "è una" sia definita mediante ereditarietà o implementazione dell'interfaccia.

Se la caldaia di implementazione di questi metodi per classe è troppo, puoi scrivere una funzione che prende un oggetto e costruisce riflettentemente un LambdaProxycercando un nome di proprietà specifico:

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

Ciò fallisce in modo abissale quando vengono dati oggetti di tipo errato; la riflessione introduce intrinsecamente la possibilità di guasti che il sistema di tipo C # non può prevenire. Fortunatamente puoi evitare la riflessione fino a quando il carico di manutenzione degli helper non diventa troppo grande perché non è necessario modificare l'interfaccia IProxy o l'implementazione LambdaProxy per aggiungere lo zucchero riflettente.

Parte del motivo per cui funziona LambdaProxyè "massimamente generico"; può adattare qualsiasi valore che implementi lo "spirito" del contratto IProxy poiché l'implementazione di LambdaProxy è completamente definita dalle date funzioni getter e setter. Funziona anche se le classi hanno nomi diversi per la proprietà, o tipi diversi che sono ragionevolmente e in modo sicuro rappresentabili come ints, o se c'è un modo per mappare il concetto che Propertydovrebbe rappresentare a qualsiasi altra caratteristica della classe. La direzione indiretta fornita dalle funzioni offre la massima flessibilità.


Un approccio molto interessante, e può sicuramente essere usato per chiamare le funzioni, ma può essere usato per factory, dove ho davvero bisogno di creare oggetti di ClassA e ClassB?
Vladimir Stokic,

@VladimirStokic Vedi le modifiche, mi sono ampliato un po 'su questo
Jack

sicuramente questo metodo richiede ancora di mappare esplicitamente la proprietà per ogni tipo con la possibilità aggiunta di un errore di runtime se la tua funzione di mappatura è difettosa
Ewan,

In alternativa a ReflectiveProxier, potresti costruire un proxy usando la dynamicparola chiave? Mi sembra che avresti gli stessi problemi fondamentali (ovvero errori che vengono rilevati solo in fase di esecuzione), ma la sintassi e la manutenibilità sarebbero molto più semplici.
Bobson,

1
@ Jack - Abbastanza giusto. Ho aggiunto la mia risposta dimostrandola. È una funzione molto utile, in alcune rare circostanze (come questa).
Bobson,

12

Ecco uno schema su come usare gli adattatori senza ereditare da A e / o B, con la possibilità di usarli per gli oggetti A e B esistenti:

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
}

Preferirei di solito questo tipo di adattatore per oggetti rispetto ai proxy di classe, evitano i brutti problemi che puoi incontrare con l'ereditarietà. Ad esempio, questa soluzione funzionerà anche se A e B sono classi sigillate.


Perché new int Property? non stai oscurando nulla.
pinkfloydx33,

@ pinkfloydx33: solo un refuso, l'ho cambiato, grazie.
Doc Brown,

9

Potresti adattarti ClassAe ClassBattraverso un'interfaccia comune. In questo modo il tuo codice LogicAToBeAppliedrimane lo stesso. Non molto diverso da quello che hai però.

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 utilizzando il modello di adattatore è la tradizionale soluzione OOP qui. E 'più di un adattatore di un proxy da quando ci adattiamo i A, Btipi di un'interfaccia comune. Il grande vantaggio è che non dobbiamo duplicare la logica comune. Lo svantaggio è che la logica ora crea un'istanza del wrapper / proxy anziché del tipo effettivo.
amon,

5
Il problema con questa soluzione è che non puoi semplicemente prendere due oggetti di tipo A e B, convertirli in qualche modo in AProxy e BProxy e quindi applicare LogicToBeApplied a loro. Questo problema può essere risolto utilizzando l'aggregazione anziché l'ereditarietà (risp. Implementare gli oggetti proxy non derivando da A e B, ma prendendo un riferimento agli oggetti A e B). Ancora un esempio di come l'uso errato dell'ereditarietà causi problemi.
Doc Brown,

@DocBrown Come andrebbe in questo caso particolare?
Vladimir Stokic,

1
@Jack: questo tipo di soluzione ha senso quando LogicToBeAppliedha una certa complessità e non dovrebbe essere ripetuto in due punti della base di codice in nessuna circostanza. Quindi il codice aggiuntivo del boilerplate è spesso trascurabile.
Doc Brown,

1
@Jack Dov'è la ridondanza? Le due classi non hanno un'interfaccia comune. Si crea involucri che non dispongono di un'interfaccia comune. Usi quell'interfaccia comune per implementare la tua logica. Non è che la stessa ridondanza non esista nel codice C ++ - è semplicemente nascosta dietro un po 'di generazione del codice. Se ti senti così fortemente su cose che sembrano uguali, anche se non sono le stesse, puoi sempre usare T4 o qualche altro sistema di template.
Luaan,

8

La versione C ++ funziona solo perché i suoi modelli usano la "tipizzazione duck statica" - tutto viene compilato purché il tipo fornisca i nomi corretti. È più simile a un sistema macro. Il sistema generico di C # e altre lingue funziona in modo molto diverso.

Le risposte di devnull e Doc Brown mostrano come è possibile utilizzare il modello di adattatore per mantenere l'algoritmo generale e continuare a operare su tipi arbitrari ... con un paio di restrizioni. In particolare, ora stai creando un tipo diverso da quello che desideri effettivamente.

Con un po 'di trucco, è possibile utilizzare esattamente il tipo previsto senza alcuna modifica. Tuttavia, ora è necessario estrarre tutte le interazioni con il tipo di destinazione in un'interfaccia separata. Qui, queste interazioni sono la costruzione e l'assegnazione della proprietà:

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

In un'interpretazione OOP, questo sarebbe un esempio del modello di strategia , sebbene mescolato con generici.

Possiamo quindi riscrivere la vostra logica per usare queste interazioni:

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

Le definizioni di interazione sarebbero:

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

Il grande svantaggio di questo approccio è che il programmatore deve scrivere e passare un'istanza di interazione quando chiama la logica. Questo è abbastanza simile alle soluzioni basate sull'adattatore, ma è leggermente più generale.

Nella mia esperienza, questo è il più vicino che puoi ottenere alle funzioni del modello in altre lingue. Tecniche simili sono utilizzate in Haskell, Scala, Go e Rust per implementare interfacce al di fuori di una definizione di tipo. Tuttavia, in queste lingue il compilatore interviene e seleziona implicitamente l'istanza di interazione corretta in modo da non vedere l'argomento aggiuntivo. Anche questo è simile ai metodi di estensione di C #, ma non è limitato ai metodi statici.


Approccio interessante Non quello che sarebbe la mia prima scelta, ma immagino che possa avere dei vantaggi quando si scrive un framework o qualcosa del genere.
Doc Brown,

8

Se vuoi davvero prestare attenzione al vento, puoi usare "dinamico" per fare in modo che il compilatore si occupi di tutta la cattiveria dei riflessi per te. Ciò comporterà un errore di runtime se si passa un oggetto a SetSomeProperty che non ha una proprietà denominata 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

Le altre risposte identificano correttamente il problema e forniscono soluzioni praticabili. C # non supporta (in genere) la "tipizzazione anatra" ("Se cammina come un'anatra ..."), quindi non c'è modo di forzare il tuo ClassAe ClassBdi essere intercambiabili se non sono stati progettati in questo modo.

Tuttavia, se sei già disposto ad accettare il rischio di un errore di runtime, allora c'è una risposta più semplice rispetto all'utilizzo di Reflection.

C # ha la dynamicparola chiave che è perfetta per situazioni come questa. Dice al compilatore "Non saprò di che tipo si tratta fino al runtime (e forse nemmeno allora), quindi permettimi di fare qualsiasi cosa".

Usando quello, puoi costruire esattamente la funzione che desideri:

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

Nota anche l'uso della staticparola chiave. Ciò ti consente di usarlo come:

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

Non ci sono implicazioni sulle prestazioni del quadro generale dell'uso dynamic, il modo in cui esiste il successo una tantum (e una maggiore complessità) dell'uso di Reflection. La prima volta che il tuo codice raggiunge la chiamata dinamica con un tipo specifico , avrai un piccolo sovraccarico , ma le chiamate ripetute saranno veloci quanto il codice standard. Tuttavia, si dovrà ottenere un RuntimeBinderExceptionse si tenta di passare a qualcosa che non ha quella proprietà, e non c'è buon modo per controllare che prima del tempo. Potresti voler gestire quell'errore in modo utile.


Questo può essere lento, ma spesso il codice lento non è un problema.
Ian,

@Ian - Buon punto. Ho aggiunto qualcosa in più sulla performance. In realtà non è così male come penseresti, a condizione che tu stia riutilizzando le stesse classi negli stessi punti.
Bobson,

Ricorda che nei modelli C ++ non c'è nemmeno il sovraccarico dei metodi virtuali!
Ian

2

È possibile utilizzare reflection per estrarre la proprietà in base al 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;
    }
}

Ovviamente si rischia un errore di runtime con questo metodo. Questo è ciò che C # sta cercando di impedirti di fare.

Ho letto da qualche parte che una versione futura di C # ti consentirà di passare oggetti come un'interfaccia che non ereditano ma corrispondono. Che risolverebbe anche il tuo problema.

(Proverò a scavare l'articolo)

Un altro metodo, anche se non sono sicuro che ti salvi alcun codice, sarebbe quello di sottoclassare sia A che B e anche ereditare un'interfaccia con IntProperty.

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

public class A2 : A, IIntProp {}

public class B2 : B, IIntProp {}

La possibilità di errori di runtime e problemi di prestazioni sono i motivi per cui non volevo riflettere. Sono comunque molto interessato a leggere l'articolo che hai citato nella tua risposta. In attesa di leggerlo.
Vladimir Stokic,

1
sicuramente prendi lo stesso rischio con la tua soluzione c ++?
Ewan,

4
@Ewan no, c ++ controlla il membro al momento della compilazione
Caleth,

Riflessione significa problemi di ottimizzazione e (cosa ancora più importante) errori di runtime difficili da eseguire il debug. Ereditarietà e un'interfaccia comune significa dichiarare in anticipo una sottoclasse per ognuna di queste classi (nessun modo per crearne una in modo anonimo sul posto) e non funziona se non usano sempre lo stesso nome di proprietà.
Jack

1
@Jack ci sono aspetti negativi, ma considera che la riflessione è ampiamente usata in Mapper, Serializzatori, Framework di iniezione di dipendenza ecc. E che l'obiettivo è farlo con il minor numero di duplicazioni del codice
Ewan,

0

Volevo solo usare le implicit operatorconversioni insieme all'approccio delegato / lambda della risposta di Jack. Ae Bsono come ipotizzato:

// A and B are mutable reference types

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

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

Quindi è facile ottenere una buona sintassi con conversioni implicite definite dall'utente (non sono necessari metodi di estensione o simili):

// 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(), };
}

Illustrazione d'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;
  }
}

Il Initializemetodo mostra come puoi lavorare Adaptersenza preoccuparti se si tratta di un Ao unB o qualcos'altro. Le invocazioni del Initializemetodo mostrano che non abbiamo bisogno di alcun cast (visibile) .AsProxy()o simile per trattare il calcestruzzo Ao Bcome un Adapter.

Considera se vuoi lanciare un ArgumentNullException nelle conversioni definite dall'utente se l'argomento passato è un riferimento null o no.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.