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
@ Джошуа, я думаю об этом больше как о проблеме с интерфейсами, не поддерживающими «типизацию утки».
Ян

Ответы:

49

Добавьте интерфейс прокси (иногда называемый адаптером , иногда с небольшими различиями), реализуйте его LogicToBeAppliedв терминах прокси, а затем добавьте способ создания экземпляра этого прокси из двух лямбд: один для свойства get и один для набора.

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
@ Джек - Достаточно справедливо. Я добавил свой ответ, демонстрируя его. Это очень полезная функция в некоторых редких случаях (например, в этом).
Бобсон
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 { }
devnull
источник
1
+1 с использованием шаблона адаптера является традиционным решением ООП здесь. Это больше , чем адаптер прокси , так как мы адаптировать A, Bтип в общий интерфейс. Большим преимуществом является то, что нам не нужно дублировать общую логику. Недостатком является то, что логика теперь создает экземпляр оболочки / прокси вместо фактического типа.
Амон
5
Проблема этого решения заключается в том, что вы не можете просто взять два объекта типа A и B, каким-то образом преобразовать их в AProxy и BProxy, а затем применить к ним LogicToBeApplied. Эта проблема может быть решена путем использования агрегации вместо наследования (соответственно реализуйте прокси-объекты не путем получения от A и B, а с помощью ссылки на объекты A и B). Снова пример того, как неправильное использование наследования вызывает проблемы.
Док Браун
@DocBrown Как это пойдет в этом конкретном случае?
Владимир Стокич
1
@Jack: такое решение имеет смысл, когда LogicToBeAppliedимеет определенную сложность и не должно повторяться в двух местах кодовой базы ни при каких обстоятельствах. Тогда дополнительный шаблонный код часто пренебрежимо мал.
Док Браун
1
@ Джек Где избыточность? Два класса не имеют общего интерфейса. Вы создаете обертки , которые действительно имеют общий интерфейс. Вы используете этот общий интерфейс для реализации своей логики. Это не то же самое, что избыточность не существует в коде 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;
        }
    }
}
OldFart
источник
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если вы попытаетесь передать то , что не имеет это имущество, и нет хорошего способа проверить , что загодя. Вы можете специально обработать эту ошибку полезным способом.

Bobson
источник
Это может быть медленным, но часто медленный код не является проблемой.
Ян
@ Ян - Хороший вопрос. Я добавил немного больше о производительности. Это на самом деле не так плохо, как вы думаете, при условии, что вы используете одни и те же классы в тех же местах.
Бобсон
Помните, в шаблонах 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 {}
Ewan
источник
Возможность ошибок во время выполнения и проблемы с производительностью - вот причины, по которым я не хотел идти с отражением. Мне, однако, очень интересно прочитать статью, которую вы упомянули в своем ответе. С нетерпением жду, чтобы прочитать это.
Владимир Стокич
1
Конечно, вы принимаете тот же риск с вашим решением C ++?
Эван
4
@ Иван нет, c ++ проверяет член во время компиляции
Caleth
Отражение означает проблемы оптимизации и (что гораздо важнее) сложные ошибки во время выполнения. Наследование и общий интерфейс означают предварительное объявление подкласса для каждого из этих классов (невозможно сделать его анонимным на месте) и не работают, если они не используют одно и то же имя свойства каждый раз.
Джек
1
@ Джек, есть и недостатки, но учтите, что рефлексия широко используется в Mappers, Serializer, 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добавить пользовательские преобразования, если переданный аргумент является пустой ссылкой или нет.

Джепп Стиг Нильсен
источник