C # Generics - Jak uniknąć zbędnej metody?

28

Załóżmy, że mam dwie klasy, które wyglądają tak (pierwszy blok kodu i ogólny problem są związane z C #):

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

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

Klasy te nie mogą być w żaden sposób zmieniane (są częścią zespołu zewnętrznego). Dlatego nie mogę zmusić ich do wdrożenia tego samego interfejsu ani dziedziczenia tej samej klasy, która zawierałaby IntProperty.

Chcę zastosować logikę do IntPropertywłaściwości obu klas, aw C ++ mógłbym użyć klasy szablonów, aby to zrobić dość łatwo:

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

};

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

A potem mógłbym zrobić coś takiego:

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

W ten sposób mogłem stworzyć jedną ogólną klasę fabryczną, która działałaby zarówno dla ClassA, jak i ClassB.

Jednak w języku C # muszę napisać dwie klasy z dwoma różnymi whereklauzulami, mimo że kod dla logiki jest dokładnie taki sam:

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

Wiem, że jeśli chcę mieć różne klasy w where klauzuli, muszą one być powiązane, tj. Aby odziedziczyć tę samą klasę, jeśli chcę zastosować do nich ten sam kod w sensie, który opisałem powyżej. Po prostu dwie denerwujące metody są bardzo denerwujące. Nie chcę też używać refleksji z powodu problemów z wydajnością.

Czy ktoś może zasugerować jakieś podejście, w którym można to napisać w bardziej elegancki sposób?

Vladimir Stokic
źródło
3
Dlaczego w tym celu używasz generycznych? W tych dwóch funkcjach nie ma nic ogólnego.
Luaan,
1
@Luaan To jest uproszczony przykład odmiany abstrakcyjnego wzorca fabrycznego. Wyobraź sobie, że istnieją dziesiątki klas, które dziedziczą ClassA lub ClassB, a ClassA i ClassB są klasami abstrakcyjnymi. Klasy odziedziczone nie zawierają żadnych dodatkowych informacji i muszą zostać utworzone. Zamiast napisać fabrykę dla każdego z nich, zdecydowałem się na użycie leków generycznych.
Vladimir Stokic
6
Cóż, możesz użyć refleksji lub dynamiki, jeśli masz pewność, że nie złamią tego w przyszłych wydaniach.
Casey
To właściwie moja największa skarga na leki generyczne, ponieważ nie może tego zrobić.
Joshua
1
@Joshua, myślę o tym bardziej jako o problemie z interfejsami nie obsługującymi „pisania kaczego”.
Ian

Odpowiedzi:

49

Dodaj interfejs proxy (czasami nazywany adapterem , czasami z niewielkimi różnicami), zaimplementuj go LogicToBeAppliedw kategoriach proxy, a następnie dodaj sposób na zbudowanie instancji tego proxy z dwóch lambd: jednego dla get get i jednego dla zestawu.

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

Teraz, ilekroć potrzebujesz przekazać IProxy, ale masz instancję klasy innej firmy, możesz po prostu przekazać niektóre lambdy:

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

Ponadto możesz pisać proste pomocniki do tworzenia instancji LamdaProxy z instancji A lub B. Mogą to być nawet metody rozszerzeń, które zapewnią ci „płynny” styl:

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

A teraz konstrukcja serwerów proxy wygląda następująco:

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

Jeśli chodzi o twoją fabrykę, zobaczyłbym, czy możesz ją przekształcić w „główną” metodę fabryczną, która akceptuje IProxy i wykonuje na nim całą logikę oraz inne metody, które właśnie przechodzą new A().Proxied()lub 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;
    }
}

Nie ma możliwości zrobienia odpowiednika kodu C ++ w C #, ponieważ szablony C ++ opierają się na typowaniu strukturalnym . Tak długo, jak dwie klasy mają tę samą nazwę i sygnaturę metody, w C ++ możesz wywoływać tę metodę ogólnie na obu z nich. C # ma typowe wpisywanie - nazwa klasy lub interfejsu jest częścią jego typu. Dlatego klasy AiB nie mogą być traktowane tak samo w żadnym przypadku, chyba że jawna relacja „jest” jest zdefiniowana przez dziedziczenie lub implementację interfejsu.

Jeśli podstawowa implementacja tych metod dla każdej klasy jest zbyt duża, możesz napisać funkcję, która pobiera obiekt i buduje w sposób refleksyjnyLambdaProxy , szukając określonej nazwy właściwości:

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

Nie udaje się to fatalnie, gdy dane obiekty są niepoprawnego typu; refleksja z natury wprowadza możliwość awarii, których system typu C # nie może zapobiec. Na szczęście możesz uniknąć refleksji, dopóki obciążenie związane z utrzymaniem pomocników nie stanie się zbyt duże, ponieważ nie musisz modyfikować interfejsu IProxy ani implementacji LambdaProxy, aby dodać cukier refleksyjny.

Jednym z powodów, dla których to działa, LambdaProxyjest „maksymalnie ogólny”; może dostosować dowolną wartość, która implementuje „ducha” kontraktu IProxy, ponieważ implementacja LambdaProxy jest całkowicie zdefiniowana przez dane funkcje pobierające i ustawiające. Działa nawet, jeśli klasy mają różne nazwy właściwości lub różne typy, które są rozsądnie i bezpiecznie reprezentowane jako ints, lub jeśli istnieje jakiś sposób odwzorowania pojęcia, które Propertyma reprezentować dowolne inne cechy klasy. Pośrednictwo zapewniane przez funkcje zapewnia maksymalną elastyczność.

Jacek
źródło
Bardzo interesujące podejście, które z pewnością można wykorzystać do wywoływania funkcji, ale czy można go użyć w fabryce, gdzie faktycznie muszę tworzyć obiekty klasy A i klasy B?
Vladimir Stokic
@VladimirStokic Zobacz zmiany, rozszerzyłem nieco ten temat
Jack
z pewnością ta metoda nadal wymaga jawnego mapowania właściwości dla każdego typu z dodatkową możliwością wystąpienia błędu czasu wykonywania, jeśli funkcja mapowania jest wadliwa
Ewan
Czy zamiast słowa kluczowego ReflectiveProxiermożna zbudować serwer proxy dynamic? Wydaje mi się, że miałbyś te same podstawowe problemy (tj. Błędy, które są wychwytywane tylko w czasie wykonywania), ale składnia i łatwość konserwacji byłyby znacznie prostsze.
Bobson
1
@Jack - Wystarczająco uczciwy. Dodałem własną odpowiedź demo. Jest to bardzo przydatna funkcja, w pewnych rzadkich okolicznościach (takich jak ta).
Bobson,
12

Oto zarys korzystania z adapterów bez dziedziczenia po A i / lub B, z możliwością użycia ich dla istniejących obiektów A i B:

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
}

Zwykle wolałbym ten rodzaj adaptera obiektowego niż proxy klasy, unikają one brzydkich problemów, na które można natknąć się z dziedziczeniem. Na przykład to rozwiązanie będzie działać, nawet jeśli A i B są klasami zapieczętowanymi.

Doktor Brown
źródło
Dlaczego new int Property? niczego nie zasłaniasz.
pinkfloydx33
@ pinkfloydx33: tylko literówka, zmieniłem to, dzięki.
Doc Brown
9

Możesz dostosować ClassAi ClassBza pomocą wspólnego interfejsu. W ten sposób Twój kod LogicAToBeAppliedpozostanie taki sam. Jednak niewiele różni się od tego, co masz.

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 { }
diabelnie
źródło
1
+1 przy użyciu Wzorca adaptera jest tutaj tradycyjnym rozwiązaniem OOP. To więcej niż adapterem pełnomocnika ponieważ dostosowanie A, Btypy do wspólnego interfejsu. Dużą zaletą jest to, że nie musimy powielać wspólnej logiki. Wadą jest to, że logika tworzy teraz instancję opakowania / proxy zamiast rzeczywistego typu.
amon
5
Problem z tym rozwiązaniem polega na tym, że nie można po prostu wziąć dwóch obiektów typu A i B, przekonwertować je w jakiś sposób na AProxy i BProxy, a następnie zastosować do nich LogicToBeApplied. Problem ten można rozwiązać, stosując agregację zamiast dziedziczenia (odpowiednio. Implementuj obiekty proxy nie wywodząc się z A i B, ale odwołując się do obiektów A i B). Znów przykład tego, jak nieprawidłowe użycie dziedziczenia powoduje problemy.
Doc Brown
@DocBrown Jak by to wyglądało w tym konkretnym przypadku?
Vladimir Stokic
1
@Jack: tego rodzaju rozwiązanie ma sens, gdy LogicToBeAppliedma pewną złożoność i nie powinno się go powtarzać w dwóch miejscach w bazie kodu pod żadnym pozorem. Wówczas dodatkowy kod płyty kotłowej jest często nieistotny.
Doc Brown
1
@ Jack Gdzie jest nadmiarowość? Dwie klasy nie mają wspólnego interfejsu. Tworzysz opakowania, które mają wspólny interfejs. Używasz tego wspólnego interfejsu do implementacji swojej logiki. To nie jest tak, że ta sama nadmiarowość nie istnieje w kodzie C ++ - jest po prostu ukryta za odrobiną generowania kodu. Jeśli czujesz to mocno w rzeczach, które wyglądają tak samo, nawet jeśli nie takie same, zawsze możesz użyć T4 lub innego systemu szablonów.
Luaan,
8

Wersja C ++ działa tylko dlatego, że jej szablony używają „statycznego pisania kaczego” - wszystko się kompiluje, o ile typ zawiera poprawne nazwy. To bardziej przypomina system makr. Ogólny system C # i innych języków działa zupełnie inaczej.

Odpowiedzi devnulla i Doca Browna pokazują, w jaki sposób można użyć wzorca adaptera, aby utrzymać ogólny algorytm i nadal działać na dowolnych typach… z kilkoma ograniczeniami. W szczególności tworzysz teraz inny typ niż chcesz.

Przy odrobinie podstępu możliwe jest użycie dokładnie zamierzonego typu bez żadnych zmian. Jednak teraz musimy wyodrębnić wszystkie interakcje z typem docelowym do osobnego interfejsu. Tutaj te interakcje to budowa i przypisanie własności:

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

W interpretacji OOP byłby to przykład wzorca strategii , choć mieszany z rodzajami.

Następnie możemy przepisać Twoją logikę, aby użyć tych interakcji:

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

Definicje interakcji wyglądałyby następująco:

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

Dużą wadą tego podejścia jest to, że programista musi napisać i przekazać instancję interakcji podczas wywoływania logiki. Jest to dość podobne do rozwiązań opartych na wzorcach adapterów, ale jest nieco bardziej ogólne.

Z mojego doświadczenia wynika, że ​​jest to najbliższa funkcja szablonów w innych językach. Podobne techniki są używane w Haskell, Scala, Go i Rust do implementacji interfejsów poza definicją typu. Jednak w tych językach kompilator wkracza i domyślnie wybiera poprawną instancję interakcji, aby w rzeczywistości nie zobaczyć dodatkowego argumentu. Jest to również podobne do metod rozszerzenia C #, ale nie jest ograniczone do metod statycznych.

amon
źródło
Ciekawe podejście Nie ten, który byłby moim pierwszym wyborem, ale myślę, że może mieć pewne zalety przy pisaniu frameworka lub czegoś takiego.
Doc Brown
8

Jeśli naprawdę chcesz rzucić ostrożność na wiatr, możesz użyć „dynamiki”, aby kompilator zadbał o wszystkie nieprzyjemności odbicia. Spowoduje to błąd środowiska wykonawczego, jeśli przekażesz obiekt do SetSomeProperty, który nie ma właściwości o nazwie 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
źródło
4

Pozostałe odpowiedzi poprawnie identyfikują problem i zapewniają wykonalne rozwiązania. C # nie (ogólnie) obsługuje „pisanie kaczek” („Jeśli chodzi jak kaczka ...”), więc nie ma sposobu, aby wymusić twoje ClassAi ClassBbyć wymiennym, jeśli nie zostały zaprojektowane w ten sposób.

Jeśli jednak jesteś gotów zaakceptować ryzyko wystąpienia błędu w czasie wykonywania, istnieje łatwiejsza odpowiedź niż użycie Reflection.

C # ma dynamicsłowo kluczowe, które jest idealne w takich sytuacjach. Mówi kompilatorowi: „Nie będę wiedział, jaki to typ, dopóki nie zostanie uruchomiony (a może nawet wtedy), więc pozwólcie mi cokolwiek z tym zrobić”.

Korzystając z tego, możesz zbudować dokładnie taką funkcję, jaką chcesz:

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

Zwróć także uwagę na użycie staticsłowa kluczowego. Dzięki temu możesz użyć tego jako:

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

dynamicKorzystanie z Refleksji nie ma wpływu na wydajność , ponieważ jest to jednorazowe działanie (i dodatkowa złożoność). Za pierwszym razem, gdy Twój kod uderzy w wywołanie dynamiczne określonego typu, będzie miał niewielki narzut , ale powtarzane połączenia będą tak samo szybkie, jak kod standardowy. Jednak będzie uzyskać RuntimeBinderException, jeśli starają się przechodzić w coś, co nie ma tej własności, i nie ma dobry sposób, aby sprawdzić, czy z wyprzedzeniem. Być może warto w konkretny sposób poradzić sobie z tym błędem.

Bobson
źródło
Może to być powolne, ale często powolny kod nie stanowi problemu.
Ian
@Ian - Dobra uwaga. Dodałem trochę więcej o wydajności. Nie jest tak źle, jak mogłoby się wydawać, pod warunkiem, że ponownie używasz tych samych klas w tych samych miejscach.
Bobson,
Pamiętaj, że w szablonach C ++ nie ma nawet narzutu metod wirtualnych!
Ian
2

Możesz użyć odbicia, aby wyciągnąć właściwość według nazwy.

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

Oczywiście tą metodą ryzykujesz błąd w czasie wykonywania. Właśnie to C # próbuje powstrzymać.

Czytałem gdzieś, że przyszła wersja C # pozwoli ci przekazywać obiekty jako interfejs, którego nie dziedziczą, ale pasują do siebie. Co również rozwiązałoby twój problem.

(Spróbuję wykopać artykuł)

Inną metodą, chociaż nie jestem pewien, czy oszczędza ci to żadnego kodu, byłoby podklasowanie A i B, a także dziedziczenie interfejsu z IntProperty.

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

public class A2 : A, IIntProp {}

public class B2 : B, IIntProp {}
Ewan
źródło
Możliwość wystąpienia błędów w czasie wykonywania i problemy z wydajnością powodują, że nie chciałem się zastanawiać. Jestem jednak bardzo zainteresowany przeczytaniem artykułu, który wspomniałeś w swojej odpowiedzi. Nie mogę się doczekać, aby go przeczytać.
Vladimir Stokic
1
na pewno podejmujesz takie samo ryzyko z rozwiązaniem c ++?
Ewan
4
@Ewan nie, c ++ sprawdza członka w czasie kompilacji
Caleth
Odbicie oznacza problemy z optymalizacją i (co ważniejsze) trudne do debugowania błędy czasu wykonywania. Dziedziczenie i wspólny interfejs oznaczają zadeklarowanie podklasy z wyprzedzeniem dla każdej z tych klas (nie ma możliwości, aby uczynić ją anonimową na miejscu) i nie działa, jeśli nie używają tej samej nazwy właściwości za każdym razem.
Jack
1
@Jack istnieją wady, ale należy wziąć pod uwagę, że refleksja jest szeroko stosowana w Mapperach, serializatorach, platformach wstrzykiwania zależności itp. I że celem jest zrobienie tego przy jak najmniejszym duplikowaniu kodu
Ewan
0

Chciałem po prostu użyć implicit operatorkonwersji razem z podejściem delegata / lambda odpowiedzi Jacka. Ai Bsą zgodne z założeniami:

// A and B are mutable reference types

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

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

Dzięki temu łatwo jest uzyskać ładną składnię z niejawnymi konwersjami zdefiniowanymi przez użytkownika (nie są wymagane żadne metody rozszerzenia ani podobne):

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

Ilustracja użycia:

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

Te Initializemetody wykazuje, w jaki sposób można pracować z Adapternie dbając o to, czy jest to Alub Bczy coś innego. Wywołanie Initializemetody pokazuje, że nie potrzebujemy żadnego (widocznego) odlewu .AsProxy()lub podobnego materiału do obróbki betonu Alub Bjako Adapter.

Zastanów się, czy chcesz wrzucić ArgumentNullExceptionkonwersje zdefiniowane przez użytkownika, jeśli przekazany argument jest odwołaniem zerowym, czy nie.

Jeppe Stig Nielsen
źródło