Génériques C # - Comment éviter la méthode redondante?

28

Supposons que j'ai deux classes qui ressemblent à ceci (le premier bloc de code et le problème général sont liés à C #):

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

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

Ces classes ne peuvent en aucun cas être modifiées (elles font partie d'un assemblage tiers). Par conséquent, je ne peux pas leur faire implémenter la même interface ou hériter de la même classe qui contiendrait alors IntProperty.

Je veux appliquer une logique sur la IntPropertypropriété des deux classes, et en C ++, je pourrais utiliser une classe de modèle pour le faire assez facilement:

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

};

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

Et puis je pourrais faire quelque chose comme ça:

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

De cette façon, je pouvais créer une seule classe d'usine générique qui fonctionnerait à la fois pour ClassA et ClassB.

Cependant, en C #, je dois écrire deux classes avec deux whereclauses différentes même si le code de la logique est exactement le même:

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

Je sais que si je veux avoir différentes classes dans la whereclause, elles doivent être liées, c'est-à-dire hériter de la même classe, si je veux leur appliquer le même code dans le sens que j'ai décrit ci-dessus. C'est juste qu'il est très ennuyeux d'avoir deux méthodes complètement identiques. Je ne veux pas non plus utiliser la réflexion à cause des problèmes de performances.

Quelqu'un peut-il suggérer une approche où cela peut être écrit d'une manière plus élégante?

Vladimir Stokic
la source
3
Pourquoi utilisez-vous des génériques pour cela en premier lieu? Il n'y a rien de générique dans ces deux fonctions.
Luaan
1
@Luaan Ceci est un exemple simplifié d'une variation du modèle d'usine abstrait. Imaginez qu'il existe des dizaines de classes qui héritent de ClassA ou ClassB, et que ClassA et ClassB sont des classes abstraites. Les classes héritées ne contiennent aucune information supplémentaire et doivent être instanciées. Au lieu d'écrire une usine pour chacun d'eux, j'ai opté pour des génériques.
Vladimir Stokic
6
Eh bien, vous pouvez utiliser la réflexion ou la dynamique si vous êtes sûr qu'ils ne vont pas le casser dans les prochaines versions.
Casey
C'est en fait ma plus grande plainte concernant les génériques, c'est qu'il ne peut pas faire cela.
Joshua
1
@Joshua, je pense que c'est plutôt un problème avec les interfaces qui ne prennent pas en charge le "typage du canard".
Ian

Réponses:

49

Ajoutez une interface proxy (parfois appelée adaptateur , parfois avec des différences subtiles), implémentez-la LogicToBeApplieden termes de proxy, puis ajoutez un moyen de construire une instance de ce proxy à partir de deux lambdas: une pour la propriété get et une pour l'ensemble.

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

Maintenant, chaque fois que vous devez passer un IProxy mais avoir une instance des classes tierces, vous pouvez simplement passer quelques 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

De plus, vous pouvez écrire des aides simples pour construire des instances LamdaProxy à partir d'instances A ou B. Il peut même s'agir de méthodes d'extension pour vous donner un style "fluide":

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

Et maintenant, la construction de procurations ressemble à ceci:

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

Quant à votre usine, je verrais si vous pouvez la refactoriser en une méthode d'usine "principale" qui accepte un IProxy et exécute toute la logique dessus et d'autres méthodes qui passent simplement 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;
    }
}

Il n'y a aucun moyen de faire l'équivalent de votre code C ++ en C # car les modèles C ++ reposent sur un typage structurel . Tant que deux classes ont le même nom et la même signature de méthode, en C ++, vous pouvez appeler cette méthode de manière générique sur les deux. C # a un typage nominal - le nom d'une classe ou d'une interface fait partie de son type. Par conséquent, les classes Aet Bne peuvent pas être traitées de la même manière à quelque titre que ce soit, sauf si une relation explicite "est une" est définie par héritage ou implémentation d'interface.

Si la mise en œuvre de ces boilerplate méthodes par classe est trop, vous pouvez écrire une fonction qui prend un objet et pensivement construit un LambdaProxyen recherchant un nom de propriété spécifique:

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

Cela échoue lamentablement quand on donne des objets de type incorrect; la réflexion introduit par nature la possibilité de défaillances que le système de type C # ne peut pas empêcher. Heureusement, vous pouvez éviter la réflexion jusqu'à ce que la charge de maintenance des assistants devienne trop importante, car vous n'êtes pas obligé de modifier l'interface IProxy ou la mise en œuvre de LambdaProxy pour ajouter le sucre réfléchissant.

Une partie de la raison pour laquelle cela fonctionne est qu'il LambdaProxyest "au maximum générique"; il peut adapter n'importe quelle valeur qui implémente "l'esprit" du contrat IProxy car l'implémentation de LambdaProxy est complètement définie par les fonctions getter et setter données. Cela fonctionne même si les classes ont des noms différents pour la propriété, ou différents types qui sont sensiblement et en toute sécurité représentables en tant que ints, ou s'il existe un moyen de mapper le concept qui Propertyest censé représenter à d'autres fonctionnalités de la classe. L'indirection fournie par les fonctions vous offre une flexibilité maximale.

Jack
la source
Une approche très intéressante, et peut certainement être utilisée pour appeler les fonctions, mais peut-elle être utilisée pour l'usine, où j'ai réellement besoin de créer des objets de ClassA et ClassB?
Vladimir Stokic
@VladimirStokic Voir les modifications, j'ai développé un peu plus à ce sujet
Jack
cette méthode vous oblige toujours à mapper explicitement la propriété pour chaque type avec la possibilité supplémentaire d'une erreur d'exécution si votre fonction de mappage est boguée
Ewan
Au lieu de cela ReflectiveProxier, pourriez-vous créer un proxy en utilisant le dynamicmot-clé? Il me semble que vous auriez les mêmes problèmes fondamentaux (c'est-à-dire des erreurs qui ne sont détectées qu'au moment de l'exécution), mais la syntaxe et la maintenabilité seraient beaucoup plus simples.
Bobson
1
@Jack - Assez juste. J'ai ajouté ma propre réponse en la faisant une démonstration. C'est une fonctionnalité très utile, dans certaines circonstances rares (comme celle-ci).
Bobson
12

Voici un aperçu de l'utilisation des adaptateurs sans hériter de A et / ou B, avec la possibilité de les utiliser pour les objets A et B existants:

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
}

Je préfère généralement ce type d'adaptateur d'objet aux proxys de classe, ils évitent les problèmes laids que vous pouvez rencontrer avec l'héritage. Par exemple, cette solution fonctionnera même si A et B sont des classes scellées.

Doc Brown
la source
Pourquoi new int Property? vous n'observez rien.
pinkfloydx33
@ pinkfloydx33: juste une faute de frappe, changé, merci.
Doc Brown
9

Vous pourriez vous adapter ClassAet ClassBvia une interface commune. De cette façon, votre code LogicAToBeAppliedreste le même. Pas très différent de ce que vous avez cependant.

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 { }
devnull
la source
1
+1 en utilisant le modèle d'adaptateur est la solution OOP traditionnelle ici. Il est plus d'un adaptateur qu'un proxy puisque nous adaptons les A, Btypes à une interface commune. Le gros avantage est que nous n'avons pas à reproduire la logique commune. L'inconvénient est que la logique instancie désormais le wrapper / proxy au lieu du type réel.
amon
5
Le problème avec cette solution est que vous ne pouvez pas simplement prendre deux objets de type A et B, les convertir en quelque sorte en AProxy et BProxy, puis leur appliquer LogicToBeApplied. Ce problème peut être résolu en utilisant l'agrégation au lieu de l'héritage (resp. Implémenter les objets proxy non pas en dérivant de A et B, mais en prenant une référence aux objets A et B). Encore un exemple de la façon dont une mauvaise utilisation de l'héritage cause des problèmes.
Doc Brown
@DocBrown Comment cela se passerait-il dans ce cas particulier?
Vladimir Stokic
1
@Jack: ce type de solution a du sens quand LogicToBeApplieda une certaine complexité et ne doit en aucun cas être répété à deux endroits dans la base de code. Ensuite, le code passe-partout supplémentaire est souvent négligeable.
Doc Brown
1
@Jack Où est la redondance? Les deux classes n'ont pas d'interface commune. Vous créez des emballages qui n'ont une interface commune. Vous utilisez cette interface commune pour implémenter votre logique. Ce n'est pas comme si la même redondance n'existait pas dans le code C ++ - elle était juste cachée derrière un peu de génération de code. Si vous êtes très attaché aux choses qui se ressemblent , même si elles ne sont pas identiques, vous pouvez toujours utiliser des T4 ou un autre système de modèles.
Luaan
8

La version C ++ ne fonctionne que parce que ses modèles utilisent le «typage statique du canard» - tout est compilé tant que le type fournit les noms corrects. Cela ressemble plus à un système macro. Le système générique de C # et d'autres langages fonctionne très différemment.

Les réponses de devnull et de Doc Brown montrent comment le modèle d'adaptateur peut être utilisé pour garder votre algorithme général et continuer à fonctionner sur des types arbitraires… avec quelques restrictions. En particulier, vous créez maintenant un type différent de celui que vous souhaitez réellement.

Avec un peu de ruse, il est possible d'utiliser exactement le type prévu sans aucune modification. Cependant, nous devons maintenant extraire toutes les interactions avec le type cible dans une interface distincte. Ici, ces interactions sont la construction et l'attribution de propriété:

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

Dans une interprétation de POO, ce serait un exemple du modèle de stratégie , bien que mélangé avec des génériques.

Nous pouvons ensuite réécrire votre logique pour utiliser ces interactions:

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

Les définitions d'interaction devraient ressembler à:

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

Le gros inconvénient de cette approche est que le programmeur doit écrire et transmettre une instance d'interaction lors de l'appel de la logique. Ceci est assez similaire aux solutions basées sur un modèle d'adaptateur, mais est légèrement plus général.

D'après mon expérience, c'est le plus proche que vous pouvez obtenir des fonctions de modèle dans d'autres langues. Des techniques similaires sont utilisées dans Haskell, Scala, Go et Rust pour implémenter des interfaces en dehors d'une définition de type. Cependant, dans ces langues, le compilateur intervient et sélectionne implicitement l'instance d'interaction correcte afin que vous ne voyiez pas réellement l'argument supplémentaire. Ceci est également similaire aux méthodes d'extension de C #, mais n'est pas limité aux méthodes statiques.

amon
la source
Approche intéressante. Pas celui qui serait mon premier choix, mais je suppose qu'il peut avoir certains avantages lors de l'écriture d'un framework ou quelque chose comme ça.
Doc Brown
8

Si vous voulez vraiment faire attention au vent, vous pouvez utiliser "dynamique" pour que le compilateur s'occupe de toutes les méchancetés de réflexion pour vous. Cela entraînera une erreur d'exécution si vous passez un objet à SetSomeProperty qui n'a pas de propriété nommée 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;
        }
    }
}
OldFart
la source
4

Les autres réponses identifient correctement le problème et fournissent des solutions viables. C # ne prend pas (généralement) en charge le "typage du canard" ("S'il marche comme un canard ..."), il n'y a donc aucun moyen de forcer votre ClassAet ClassBd'être interchangeable s'il n'était pas conçu de cette façon.

Cependant, si vous êtes déjà prêt à accepter le risque d'une erreur d'exécution, il y a une réponse plus simple que d'utiliser Reflection.

C # a le dynamicmot - clé qui est parfait pour des situations comme celle-ci. Il indique au compilateur "Je ne saurai pas de quel type il s'agit avant l'exécution (et peut-être même pas à ce moment-là), alors permettez-moi de faire quoi que ce soit ".

En utilisant cela, vous pouvez créer exactement la fonction que vous souhaitez:

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

Notez également l'utilisation du staticmot - clé. Cela vous permet de l'utiliser comme:

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

L' dynamicutilisation de la réflexion n'a aucune incidence sur les performances globales , la façon dont il y a le coup unique (et la complexité supplémentaire) de l'utilisation de la réflexion. La première fois que votre code frappera l'appel dynamique avec un type spécifique, il y aura une petite surcharge , mais les appels répétés seront tout aussi rapides que le code standard. Cependant, vous aurez obtenir un RuntimeBinderExceptionsi vous essayez de passer quelque chose qui n'a pas cette propriété, et il n'y a pas de bonne façon de vérifier que l' avance. Vous voudrez peut-être traiter spécifiquement cette erreur de manière utile.

Bobson
la source
Cela peut être lent, mais le code lent n'est souvent pas un problème.
Ian
@Ian - Bon point. J'ai ajouté un peu plus sur les performances. Ce n'est pas aussi mauvais que vous le pensez, à condition de réutiliser les mêmes classes aux mêmes endroits.
Bobson
N'oubliez pas que les modèles C ++ n'ont même pas la surcharge des méthodes virtuelles!
Ian
2

Vous pouvez utiliser la réflexion pour extraire la propriété par son nom.

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

Évidemment, vous risquez une erreur d'exécution avec cette méthode. C'est ce que C # essaie de vous empêcher de faire.

J'ai lu quelque part qu'une future version de C # vous permettra de passer des objets en tant qu'interface qu'ils n'héritent pas mais correspondent. Ce qui résoudrait également votre problème.

(Je vais essayer de déterrer l'article)

Une autre méthode, bien que je ne sois pas sûr qu'elle vous enregistre aucun code, serait de sous-classer A et B et d'hériter également d'une interface avec IntProperty.

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

public class A2 : A, IIntProp {}

public class B2 : B, IIntProp {}
Ewan
la source
La possibilité d'erreurs d'exécution et les problèmes de performances sont les raisons pour lesquelles je ne voulais pas aller avec la réflexion. Je suis cependant très intéressé par la lecture de l'article que vous avez mentionné dans votre réponse. En attente de le lire.
Vladimir Stokic
1
vous prenez sûrement le même risque avec votre solution c ++?
Ewan
4
@Ewan non, c ++ vérifie le membre au moment de la compilation
Caleth
La réflexion signifie des problèmes d'optimisation et (beaucoup plus important) des erreurs d'exécution difficiles à déboguer. L'héritage et une interface commune signifient déclarer une sous-classe à l'avance pour chacune de ces classes (aucun moyen d'en créer une de façon anonyme sur place) et ne fonctionne pas si elles n'utilisent pas le même nom de propriété à chaque fois.
Jack
1
@Jack, il y a des inconvénients, mais considérez que la réflexion est largement utilisée dans les mappeurs, les sérialiseurs, les infrastructures d'injection de dépendances, etc. et que l'objectif est de le faire avec le moins de duplication de code
Ewan
0

Je voulais juste utiliser des implicit operatorconversions avec l'approche déléguée / lambda de la réponse de Jack. Aet Bsont tels que supposés:

// A and B are mutable reference types

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

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

Ensuite, il est facile d'obtenir une syntaxe agréable avec des conversions implicites définies par l'utilisateur (aucune méthode d'extension ou similaire n'est nécessaire):

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

Illustration d'utilisation:

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

La Initializeméthode montre comment vous pouvez travailler avec Adaptersans se soucier de savoir si c'est un Aou un Bou quelque chose d'autre. Les invocations de la Initializeméthode montrent que nous n'avons pas besoin de fonte (visible) .AsProxy()ou similaire pour traiter le béton Aou Bcomme un Adapter.

Demandez-vous si vous souhaitez lancer un ArgumentNullExceptiondans les conversions définies par l'utilisateur si l'argument passé est une référence nulle ou non.

Jeppe Stig Nielsen
la source