C # Generics - Як уникнути зайвого методу?


28

Припустимо, у мене є два класи, які виглядають приблизно так (перший блок коду та загальна проблема пов'язані з C #):

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

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

Ці класи неможливо жодним чином змінити (вони є частиною сторонньої асамблеї). Тому я не можу змусити їх реалізувати один і той же інтерфейс або успадкувати той же клас, який би містив IntProperty.

Я хочу застосувати певну логіку до IntPropertyвластивості обох класів, і в C ++ я можу використовувати клас шаблонів, щоб зробити це досить легко:

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

};

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

І тоді я міг би зробити щось подібне:

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

Таким чином я міг би створити єдиний загальний заводський клас, який би працював і для ClassA, і для ClassB.

Однак у C # я повинен написати два класи з двома різними whereзастереженнями, хоча код логіки абсолютно однаковий:

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

Я знаю, що якщо я хочу мати різні класи в whereпункті, вони повинні бути пов'язані, тобто успадковувати той самий клас, якщо я хочу застосувати до них той самий код у тому сенсі, який я описав вище. Просто дуже прикро мати два абсолютно однакових методу. Я також не хочу використовувати рефлексію через проблеми з продуктивністю.

Чи може хтось запропонувати якийсь підхід, де це можна написати більш елегантно?


3
Чому ви в першу чергу використовуєте дженерики для цього? У цих двох функціях немає нічого загального.
Луань

1
@Luaan Це спрощений приклад варіації абстрактних заводських зразків. Уявіть, що існує десятки класів, які успадковують ClassA або ClassB, і що ClassA і ClassB - це абстрактні класи. Успадковані класи не містять додаткової інформації, і їх потрібно обґрунтувати. Замість того, щоб написати фабрику для кожного з них, я вирішив використовувати дженерики.
Володимир Стокич

6
Що ж, ви можете використовувати рефлексію чи динаміку, якщо впевнені, що вони не збираються її порушувати в майбутніх випусках.
Кейсі

Це насправді моя найбільша скарга на дженерики, що вона не може цього зробити.
Джошуа

1
@Joshua, я вважаю це скоріше проблемою з інтерфейсами, які не підтримують "набирання качок".
Ян

Відповіді:


49

Додайте проксі-інтерфейс (іноді його називають адаптером , інколи з тонкими відмінностями), LogicToBeAppliedреалізуйте з точки зору проксі, а потім додайте спосіб побудувати екземпляр цього проксі з двох лямбда: один для отримання властивості та один для набору.

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

Тепер, коли вам потрібно пройти IProxy, але мати примірник сторонніх класів, ви можете просто пройти в деяких лямбдах:

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

Крім того, ви можете написати простих помічників для побудови екземплярів LamdaProxy з екземплярів A або B. Вони можуть бути навіть методами розширення, щоб надати "вільний" стиль:

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

А зараз будівництво проксі-серверів виглядає приблизно так:

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

Що ж стосується вашого заводу, я хотів би бачити , якщо ви можете реорганізувати його в «основний» фабричний метод , який приймає IProxy і виконує всю логіку на ньому і інші методи , які просто проходять в new A().Proxied()або 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;
    }
}

Немає можливості зробити еквівалент вашого C ++ коду в C #, тому що шаблони C ++ покладаються на структурне введення тексту. Поки два класи мають однакове ім'я методу та підпис, у C ++ ви можете викликати цей метод в обох випадках. C # має номінальне введення тексту - назва класу чи інтерфейсу є частиною його типу. Таким чином, класи Aі Bне можуть розглядатися однаково в будь-якій якості, якщо явне відношення "є" не визначене через успадкування або реалізацію інтерфейсу.

Якщо шаблону реалізації цих методів для кожного класу занадто багато, ви можете написати функцію, яка займає об'єкт і рефлексивно будує LambdaProxy, шукаючи конкретну назву властивості:

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

Це виходить з ладу, якщо задані об'єкти неправильного типу; відображення по суті вводить можливість відмов системи типу C # не може запобігти. На щастя, ви можете уникнути роздумів, поки тягар технічного обслуговування помічників не стане занадто великим, оскільки вам не потрібно змінювати інтерфейс IProxy або реалізацію LambdaProxy, щоб додавати відбиває цукор.

Частина причини цього працює в тому, що LambdaProxyце "максимально загальний характер"; він може адаптувати будь-яке значення, яке реалізує "дух" договору IProxy, оскільки реалізація LambdaProxy повністю визначена заданими функціями геттера та сеттера. Це навіть працює, якщо класи мають різні назви властивості, або різні типи, які розумно і безпечно представляються як ints, або якщо є якийсь спосіб відобразити концепцію, яка Propertyповинна представляти будь-які інші особливості класу. Непрямість, що забезпечується функціями, дає вам максимальну гнучкість.


Дуже цікавий підхід, і його, безумовно, можна використовувати для виклику функцій, але чи можна його використовувати для заводських, де мені насправді потрібно створити об’єкти ClassA та ClassB?
Володимир Стокіч

@VladimirStokic Дивіться правки, я трохи розширив це
Джек

безумовно, цей метод все ще вимагає від вас чітко відобразити властивість для кожного типу з додатковою можливістю помилки виконання, якщо ваша карта відображається помилково
Ewan

Як альтернативу ReflectiveProxier, чи можете ви створити проксі-сервер за допомогою dynamicключового слова? Здається, у вас виникнуть ті самі принципові проблеми (тобто помилки, які потрапляють лише під час виконання), але синтаксис та ремонтопридатність були б набагато простішими.
Бобсон

1
@Jack - Досить справедливо. Я додав власну відповідь, демонструючи це. Це дуже корисна функція в певних рідкісних обставинах (як ця).
Бобсон

12

Ось контур, як використовувати адаптери без успадкування від A та / або B, з можливістю їх використання для існуючих об'єктів A і 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
}

Як правило, я віддаю перевагу такому об’єктовому адаптеру над проксі-серверами класу, вони уникають негарних проблем, з якими можна зіткнутися із спадщиною. Наприклад, це рішення буде працювати, навіть якщо A і B мають герметичні класи.


Чому new int Property? ти нічого не затінюєш.
pinkfloydx33

@ pinkfloydx33: просто помилка, змінила його, дякую.
Док Браун

9

Ви могли адаптуватися ClassAі ClassBчерез загальний інтерфейс. Таким чином ваш код LogicAToBeAppliedзалишається однаковим. Не сильно відрізняється від того, що у вас є.

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 за допомогою шаблону адаптера - традиційне рішення OOP. Це більше , ніж адаптер проксі , так як ми адаптувати A, Bтип в загальний інтерфейс. Велика перевага полягає в тому, що нам не доведеться дублювати загальну логіку. Недоліком є ​​те, що логіка тепер інстанціює обгортку / проксі замість фактичного типу.
амон

5
Проблема цього рішення полягає в тому, що ви не можете просто взяти два об'єкти типу A і B, перетворити їх якимось чином у AProxy та BProxy, а потім застосувати до них LogicToBeApplied. Цю проблему можна вирішити, використовуючи агрегацію замість успадкування (відповідно, реалізувати проксі-об'єкти не за походженням від A і B, а шляхом посилання на об'єкти A і B). Знову приклад того, як неправильне використання спадщини спричиняє проблеми.
Док Браун

@DocBrown Як би це було в цьому конкретному випадку?
Володимир Стокич

1
@Jack: подібне рішення має сенс, коли воно LogicToBeAppliedмає певну складність і не повинно повторюватися в двох місцях у кодовій базі ні за яких обставин. Тоді додатковий код котла часто незначний.
Док Браун

1
@Jack Де резервування? У двох класів немає спільного інтерфейсу. Ви створюєте обгортки, які мають загальний інтерфейс. Ви використовуєте цей загальний інтерфейс для реалізації своєї логіки. Це не так, як надмірність у коді C ++ не існує - вона просто прихована за трохи генерації коду. Якщо ви відчуваєте це сильно щодо речей, які виглядають однаково, незважаючи на те, що вони не однакові, ви завжди можете використовувати T4 або іншу систему шаблонів.
Луань

8

Версія C ++ працює лише тому, що в її шаблонах використовується "статична набирання качок" - все компілюється до тих пір, поки тип надає правильні назви. Це більше схоже на макросистему. Система дженерики C # та інших мов працює дуже по-різному.

Відповіді devnull і Doc Brown показують, як можна використовувати схему адаптера для загального алгоритму і все ще працювати на довільних типах… з парою обмежень. Зокрема, ви зараз створюєте інший тип, ніж ви насправді хочете.

За допомогою трохи хитрощів можна використовувати саме призначений тип без будь-яких змін. Однак зараз нам потрібно витягти всі взаємодії з цільовим типом в окремий інтерфейс. Ось ці взаємодії - це побудова та призначення властивостей:

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

У інтерпретації ООП це може бути приклад стратегії , хоча і змішаної з загальними.

Потім ми можемо переписати вашу логіку, щоб використовувати ці взаємодії:

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

Визначення взаємодії виглядатиме так:

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

Великим недоліком такого підходу є те, що програмісту потрібно записати та пройти екземпляр взаємодії під час виклику логіки. Це досить схоже на рішення на основі адаптерних моделей, але дещо більш загальне.

На мій досвід, це найближче до шаблону функцій іншими мовами. Подібні методи використовуються в Haskell, Scala, Go і Rust для реалізації інтерфейсів поза визначенням типу. Однак у цих мовах компілятор вмикається та вибирає правильний екземпляр взаємодії неявно, так що ви фактично не бачите зайвого аргументу. Це також схоже на методи розширення C #, але не обмежується статичними методами.


Цікавий підхід. Не той, який був би моїм першим вибором, але я думаю, що це може мати певні переваги при написанні рамки чи щось подібне.
Док Браун

8

Якщо ви дійсно хочете кинути обережність на вітер, ви можете скористатися "динамічним", щоб змусити компілятор подбати про всю віддзеркалення відбиття для вас. Це призведе до помилки виконання, якщо ви передасте об’єкт SetSomeProperty, який не має властивості з назвою 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

Інші відповіді правильно ідентифікують проблему та надають працездатні рішення. C # не (як правило) не підтримує "набирання качок" ("Якщо вона ходить як качка ..."), тому немає ніякого способу змусити вас ClassAі ClassBбути взаємозамінними, якщо вони не були створені таким чином.

Однак якщо ви вже готові прийняти ризик помилки під час виконання, тоді відповідь простіша, ніж використання Reflection.

C # має dynamicключове слово, яке ідеально підходить для подібних ситуацій. Він повідомляє компілятору: "Я не знаю, що це за тип до моменту виконання (а може навіть і не тоді), тому дозвольте мені взагалі щось робити ".

Використовуючи це, ви можете створити потрібну функцію:

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

Зверніть увагу і на використання staticключового слова. Це дозволяє використовувати це як:

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

Немає жодних наслідків використання великої картини використання dynamicспособу одноразового попадання (та додаткової складності) використання Reflection. Перший раз, коли ваш код потрапить на динамічний дзвінок із певним типом , матиме невелику кількість накладних витрат , але повторні дзвінки пройдуть так само швидко, як і стандартний код. Тим НЕ менше, ви будете отримати , RuntimeBinderExceptionякщо ви спробуєте передати те , що не має це майно, і немає гарного способу перевірити , що заздалегідь. Ви можете спеціально обробити цю помилку корисним чином.


Це може бути повільним, але часто повільний код не є проблемою.
Ян

@Ian - Гарний момент. Я додав трохи більше про виставу. Насправді це не так погано, як ви могли б подумати, якщо ви повторно використовуєте одні і ті ж класи в одних і тих же місцях.
Бобсон

Пам’ятайте, що в шаблонах C ++ навіть немає накладних витрат на віртуальні методи!
Ян

2

Ви можете використовувати відображення, щоб витягнути властивість за іменем.

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

Очевидно, ви ризикуєте помилкою виконання під час цього методу. Це те, що C # намагається зупинити вас.

Я десь читав, що майбутня версія C # дозволить вам передавати об'єкти як інтерфейс, який вони не успадковують, але відповідають. Що також вирішило б вашу проблему.

(Я спробую викопати статтю)

Ще одним методом, хоча я не впевнений, що він економить вам будь-який код, було б підклас і A, і B, а також успадкувати інтерфейс з IntProperty.

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

public class A2 : A, IIntProp {}

public class B2 : B, IIntProp {}

Можливість помилок виконання та проблеми виконання - це причини, через які я не хотів замислюватися. Мені дуже цікаво читати статтю, яку ви згадали у своїй відповіді. З нетерпінням чекаю його.
Володимир Стокіч

1
Ви точно приймаєте такий же ризик із рішенням c ++?
Еван

4
@Ewan ні, c ++ перевіряє члена на час компіляції
Caleth

Відображення означає проблеми з оптимізацією та (що набагато важливіше) важко відладжувати помилки виконання. Спадкування та загальний інтерфейс означає заздалегідь оголосити підклас для кожного з цих класів (ніякого способу зробити анонімний на місці) і не працює, якщо вони не використовують те саме ім’я властивості кожного разу.
Джек

1
@Jack є і негативні сторони, але вважають , що відображення широко використовується в Mappers, серіалізатор, Dependency Injection рамок і т.д. , і що мета полягає в тому, щоб зробити це з найменшою кількістю дублювання коду
Ewan

0

Я просто хотів використати implicit operatorперетворення разом із делегатським / лямбда-підходом відповіді Джека. Aі Bвважаються такими, як передбачається:

// A and B are mutable reference types

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

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

Тоді легко отримати приємний синтаксис із неявними конверсіями, визначеними користувачем (не потрібні методи розширення чи подібні):

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

Ілюстрація використання:

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

У Initializeметод показує , як ви можете працювати з , Adapterне піклуючись про те, чи є це Aабо Bабо що - то інше. Виклики Initializeметоду показують, що нам не потрібен жоден (видимий) литий .AsProxy()або подібний матеріал для обробки бетону Aабо Bяк Adapter.

Подумайте, чи хочете ви ввести ArgumentNullExceptionконверсії, визначені користувачем, якщо переданий аргумент є нульовою посиланням, чи ні.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.