C # Generics - Bagaimana cara menghindari metode yang berlebihan?

28

Mari kita asumsikan saya memiliki dua kelas yang terlihat seperti ini (blok kode pertama dan masalah umum terkait dengan C #):

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

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

Kelas-kelas ini tidak dapat diubah dengan cara apa pun (mereka adalah bagian dari majelis pihak ke-3). Oleh karena itu, saya tidak dapat membuat mereka mengimplementasikan antarmuka yang sama, atau mewarisi kelas yang sama yang kemudian akan berisi IntProperty.

Saya ingin menerapkan beberapa logika pada IntPropertyproperti kedua kelas, dan di C ++ saya bisa menggunakan kelas template untuk melakukannya dengan mudah:

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

};

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

Dan kemudian saya bisa melakukan sesuatu seperti ini:

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

Dengan begitu saya bisa membuat satu kelas pabrik generik yang dapat digunakan untuk ClassA dan ClassB.

Namun, dalam C #, saya harus menulis dua kelas dengan dua whereklausa yang berbeda walaupun kode untuk logika persis sama:

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

Saya tahu bahwa jika saya ingin memiliki kelas yang berbeda dalam whereklausa, mereka harus terkait, yaitu mewarisi kelas yang sama, jika saya ingin menerapkan kode yang sama kepada mereka dalam pengertian yang saya jelaskan di atas. Hanya saja sangat menyebalkan memiliki dua metode yang benar-benar identik. Saya juga tidak ingin menggunakan refleksi karena masalah kinerja.

Adakah yang bisa menyarankan beberapa pendekatan di mana ini dapat ditulis dengan cara yang lebih elegan?

Vladimir Stokic
sumber
3
Mengapa Anda menggunakan obat generik untuk ini? Tidak ada yang umum tentang kedua fungsi itu.
Luaan
1
@Luaan Ini adalah contoh sederhana dari variasi pola pabrik abstrak. Bayangkan ada puluhan kelas yang mewarisi ClassA atau ClassB, dan bahwa ClassA dan ClassB adalah kelas abstrak. Kelas yang diwariskan tidak membawa informasi tambahan, dan perlu dipakai. Alih-alih menulis pabrik untuk masing-masing, saya memilih untuk menggunakan obat generik.
Vladimir Stokic
6
Nah, Anda bisa menggunakan refleksi atau dinamika jika Anda yakin mereka tidak akan merusaknya di rilis mendatang.
Casey
Ini sebenarnya keluhan terbesar saya tentang obat generik adalah tidak bisa melakukan ini.
Joshua
1
@ Joshua, saya menganggapnya lebih sebagai masalah dengan antarmuka yang tidak mendukung "mengetik bebek."
Ian

Jawaban:

49

Tambahkan antarmuka proxy (kadang-kadang disebut adaptor , kadang-kadang dengan perbedaan kecil), terapkan LogicToBeApplieddalam hal proxy, lalu tambahkan cara untuk membuat instance proxy ini dari dua lambdas: satu untuk properti dapatkan dan satu untuk 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;
    }
}

Sekarang, setiap kali Anda harus lulus dalam IProxy tetapi memiliki turunan dari kelas pihak ketiga, Anda bisa memasukkan beberapa 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

Selain itu, Anda dapat menulis bantuan sederhana untuk membuat instance LamdaProxy dari instance A atau B. Mereka bahkan dapat menjadi metode ekstensi untuk memberi Anda gaya "fasih":

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

Dan sekarang konstruksi proxy terlihat seperti ini:

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

Sedangkan untuk pabrik Anda, saya akan melihat apakah Anda dapat mengubahnya menjadi metode pabrik "utama" yang menerima IProxy dan melakukan semua logika di dalamnya dan metode lain yang baru saja melewati new A().Proxied()atau 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;
    }
}

Tidak ada cara untuk melakukan hal yang sama dengan kode C ++ Anda di C # karena templat C ++ bergantung pada pengetikan struktural . Selama dua kelas memiliki nama metode dan tanda tangan yang sama, di C ++ Anda dapat memanggil metode itu secara umum pada keduanya. C # memiliki pengetikan nominal - nama kelas atau antarmuka adalah bagian dari tipenya. Oleh karena itu, kelas-kelas Adan Btidak dapat diperlakukan sama dalam kapasitas apa pun kecuali hubungan eksplisit "adalah" didefinisikan melalui warisan atau implementasi antarmuka.

Jika boilerplate menerapkan metode ini per kelas terlalu banyak, Anda dapat menulis fungsi yang mengambil objek dan secara reflektif membangun LambdaProxydengan mencari nama properti tertentu:

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

Ini gagal parah ketika diberikan objek dengan tipe yang salah; refleksi secara inheren memperkenalkan kemungkinan kegagalan sistem tipe C # tidak dapat mencegah. Untungnya Anda dapat menghindari refleksi sampai beban pemeliharaan para pembantu menjadi terlalu besar karena Anda tidak diharuskan untuk memodifikasi antarmuka IProxy atau implementasi LambdaProxy untuk menambahkan gula reflektif.

Sebagian alasan mengapa ini berhasil LambdaProxyadalah "generik secara maksimal"; itu dapat mengadaptasi nilai apa pun yang mengimplementasikan "semangat" dari kontrak IProxy karena implementasi LambdaProxy sepenuhnya ditentukan oleh fungsi pengambil dan penyetel yang diberikan. Ini bahkan berfungsi jika kelas memiliki nama yang berbeda untuk properti, atau tipe berbeda yang secara masuk akal dan aman direpresentasikan sebagai int, atau jika ada beberapa cara untuk memetakan konsep yang Propertyseharusnya mewakili fitur lain dari kelas. Tipuan yang diberikan oleh fungsi memberi Anda fleksibilitas maksimal.

Mendongkrak
sumber
Pendekatan yang sangat menarik, dan pasti dapat digunakan untuk memanggil fungsi, tetapi dapatkah itu digunakan untuk pabrik, di mana saya sebenarnya perlu membuat objek ClassA dan ClassB?
Vladimir Stokic
@VladimirStokic Lihat suntingan, saya telah sedikit memperluas ini
Jack
pasti metode ini masih mengharuskan Anda untuk secara eksplisit memetakan properti untuk setiap jenis dengan kemungkinan tambahan kesalahan runtime jika fungsi pemetaan Anda buggy
Ewan
Sebagai alternatif ReflectiveProxier, bisakah Anda membangun proxy menggunakan dynamickata kunci? Menurut saya Anda akan memiliki masalah mendasar yang sama (yaitu kesalahan yang hanya tertangkap saat runtime), tetapi sintaks dan rawatan akan jauh lebih sederhana.
Bobson
1
@ Jack - Cukup adil. Saya menambahkan jawaban saya sendiri, mengujinya. Ini fitur yang sangat berguna, dalam keadaan langka tertentu (seperti ini).
Bobson
12

Berikut adalah garis besar cara menggunakan adaptor tanpa mewarisi dari A dan / atau B, dengan kemungkinan menggunakannya untuk objek A dan B yang ada:

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
}

Saya biasanya lebih suka adaptor objek semacam ini daripada proxy kelas, mereka menghindari masalah buruk yang dapat Anda hadapi dengan warisan. Sebagai contoh, solusi ini akan bekerja bahkan jika A dan B adalah kelas yang disegel.

Doc Brown
sumber
Mengapa new int Property? Anda tidak membayangi apa pun.
pinkfloydx33
@ pinkfloydx33: hanya salah ketik, ubah saja, terima kasih.
Doc Brown
9

Anda dapat beradaptasi ClassAdan ClassBmelalui antarmuka umum. Dengan cara ini kode Anda LogicAToBeAppliedtetap sama. Tidak jauh berbeda dari apa yang Anda miliki.

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
sumber
1
+1 menggunakan Pola Adaptor adalah solusi OOP tradisional di sini. Ini lebih merupakan adaptor daripada proxy karena kami mengadaptasi A, Btipe ke antarmuka umum. Keuntungan besar adalah kita tidak perlu menduplikasi logika umum. Kerugiannya adalah bahwa logika sekarang instantiates wrapper / proxy bukan jenis yang sebenarnya.
amon
5
Masalah dengan solusi ini adalah bahwa Anda tidak bisa begitu saja mengambil dua objek tipe A dan B, mengubahnya entah bagaimana menjadi AProxy dan BProxy, dan kemudian menerapkan LogicToBeApplied kepada mereka. Masalah ini dapat diselesaikan dengan menggunakan agregasi alih-alih warisan (resp. Mengimplementasikan objek proxy bukan dengan berasal dari A dan B, tetapi dengan mengambil referensi ke objek A dan B). Lagi-lagi contoh bagaimana salah penggunaan warisan menyebabkan masalah.
Doc Brown
@DocBrown Bagaimana itu dalam kasus khusus ini?
Vladimir Stokic
1
@ Jack: solusi semacam ini masuk akal ketika LogicToBeAppliedmemiliki kompleksitas tertentu dan tidak boleh diulangi di dua tempat dalam basis kode dalam keadaan apa pun. Maka kode boilerplate tambahan sering diabaikan.
Doc Brown
1
@ Jack Dimana redundansinya? Kedua kelas tidak memiliki antarmuka yang sama. Anda membuat pembungkus yang tidak memiliki antarmuka yang sama. Anda menggunakan antarmuka umum itu untuk mengimplementasikan logika Anda. Ini tidak seperti redundansi yang sama tidak ada dalam kode C ++ - itu hanya tersembunyi di balik sedikit pembuatan kode. Jika Anda merasa sangat kuat tentang hal-hal yang terlihat sama, meskipun mereka tidak sama, Anda selalu dapat menggunakan T4s atau sistem templating lainnya.
Luaan
8

Versi C ++ hanya berfungsi karena templatnya menggunakan "static duck typing" - apa pun yang dikompilasi selama jenisnya memberikan nama yang benar. Ini lebih seperti sistem makro. Sistem generik C # dan bahasa lain bekerja sangat berbeda.

Jawaban devnull dan Doc Brown menunjukkan bagaimana pola adaptor dapat digunakan untuk menjaga algoritma Anda tetap umum, dan masih beroperasi pada tipe arbitrer ... dengan beberapa batasan. Khususnya, Anda sekarang membuat jenis yang berbeda dari yang Anda inginkan.

Dengan sedikit tipu daya, dimungkinkan untuk menggunakan tipe yang tepat tanpa perubahan apa pun. Namun, sekarang kita perlu mengekstraksi semua interaksi dengan tipe target ke antarmuka yang terpisah. Di sini, interaksi ini adalah penugasan konstruksi dan properti:

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

Dalam interpretasi OOP, ini akan menjadi contoh dari pola strategi , meskipun dicampur dengan obat generik.

Kami kemudian dapat menulis ulang logika Anda untuk menggunakan interaksi ini:

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

Definisi interaksi akan terlihat seperti:

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

Kerugian besar dari pendekatan ini adalah bahwa programmer perlu menulis dan melewatkan contoh interaksi ketika memanggil logika. Ini cukup mirip dengan solusi berbasis pola-adaptor, tetapi sedikit lebih umum.

Dalam pengalaman saya, ini adalah yang terdekat dengan fungsi templat yang Anda dapat gunakan dalam bahasa lain. Teknik serupa digunakan di Haskell, Scala, Go, dan Rust untuk mengimplementasikan antarmuka di luar definisi tipe. Namun, dalam bahasa ini kompiler masuk dan memilih contoh interaksi yang benar secara implisit sehingga Anda tidak benar-benar melihat argumen tambahan. Ini juga mirip dengan metode ekstensi C #, tetapi tidak terbatas pada metode statis.

amon
sumber
Pendekatan yang menarik. Bukan yang akan menjadi pilihan pertama saya, tapi saya kira itu bisa memiliki beberapa keuntungan saat menulis kerangka kerja atau sesuatu seperti itu.
Doc Brown
8

Jika Anda benar-benar ingin berhati-hati terhadap angin, Anda dapat menggunakan "dinamis" untuk membuat kompilator menangani semua kekotoran refleksi untuk Anda. Ini akan menghasilkan kesalahan runtime jika Anda melewatkan objek ke SetSomeProperty yang tidak memiliki properti bernama 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;
        }
    }
}
Tua bangka
sumber
4

Jawaban lain mengidentifikasi masalah dengan benar dan memberikan solusi yang bisa diterapkan. C # tidak (umumnya) mendukung "mengetik bebek" ("Jika berjalan seperti bebek ..."), jadi tidak ada cara untuk memaksa Anda ClassAdan ClassBdipertukarkan jika mereka tidak dirancang seperti itu.

Namun, jika Anda sudah bersedia menerima risiko kesalahan runtime, maka ada jawaban yang lebih mudah daripada menggunakan Refleksi.

C # memiliki dynamickata kunci yang sempurna untuk situasi seperti ini. Ia memberitahu kompiler "Saya tidak akan tahu apa jenis ini sampai runtime (dan mungkin bahkan tidak), jadi izinkan saya untuk melakukan apa saja untuk itu".

Dengan itu, Anda dapat membangun fungsi yang Anda inginkan:

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

Perhatikan penggunaan statickata kunci juga. Itu memungkinkan Anda menggunakan ini sebagai:

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

Tidak ada implikasi kinerja gambar besar menggunakan dynamic, cara ada hit satu kali (dan menambah kompleksitas) menggunakan Refleksi. Pertama kali kode Anda mengenai panggilan dinamis dengan jenis tertentu akan memiliki sedikit overhead , tetapi panggilan berulang akan sama cepatnya dengan kode standar. Namun, Anda akan mendapatkan RuntimeBinderExceptionjika Anda mencoba mengirimkan sesuatu yang tidak memiliki properti itu, dan tidak ada cara yang baik untuk memeriksanya sebelumnya. Anda mungkin ingin secara khusus menangani kesalahan itu dengan cara yang bermanfaat.

Bobson
sumber
Ini bisa lambat, tetapi sering kali kode lambat tidak menjadi masalah.
Ian
@Ian - Poin bagus. Saya menambahkan sedikit tentang kinerja. Ini sebenarnya tidak seburuk yang Anda kira, asalkan Anda menggunakan kembali kelas yang sama di tempat yang sama.
Bobson
Ingat dalam template C ++ bahkan tidak memiliki overhead metode virtual!
Ian
2

Anda dapat menggunakan refleksi untuk mengeluarkan properti dengan nama.

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

Jelas Anda berisiko kesalahan runtime dengan metode ini. Itulah yang C # berusaha menghentikan Anda lakukan.

Saya memang membaca di suatu tempat bahwa versi C # yang akan datang memungkinkan Anda untuk melewatkan objek sebagai antarmuka yang tidak mereka warisi tetapi cocok. Yang juga akan menyelesaikan masalah Anda.

(Saya akan mencoba dan menggali artikel)

Metode lain, meskipun saya tidak yakin ini akan menyelamatkan Anda kode apa pun, akan menjadi subkelas A dan B dan juga mewarisi Antarmuka dengan IntProperty.

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

public class A2 : A, IIntProp {}

public class B2 : B, IIntProp {}
Ewan
sumber
Kemungkinan kesalahan runtime dan masalah kinerja adalah alasan mengapa saya tidak ingin melakukan refleksi. Namun, saya sangat tertarik membaca artikel yang Anda sebutkan dalam jawaban Anda. Menantikan untuk dibaca.
Vladimir Stokic
1
tentunya Anda mengambil risiko yang sama dengan solusi c ++ Anda?
Ewan
4
@ Ewan tidak, c ++ memeriksa anggota pada waktu kompilasi
Caleth
Refleksi berarti masalah optimisasi dan (jauh lebih penting) kesalahan debug runtime yang sulit. Warisan dan antarmuka umum berarti mendeklarasikan subclass sebelumnya untuk setiap kelas ini (tidak ada cara untuk membuat satu secara anonim di tempat) dan tidak berfungsi jika mereka tidak menggunakan nama properti yang sama setiap saat.
Jack
1
@ Jack ada kerugian, tetapi pertimbangkan refleksi yang digunakan secara luas dalam pemetaan, Serializers, kerangka kerja Dependency Injection dll dan tujuannya adalah untuk melakukannya dengan jumlah duplikasi kode paling sedikit
Ewan
0

Saya hanya ingin menggunakan implicit operatorkonversi bersama dengan pendekatan delegasi / lambda dari jawaban Jack. Adan Bseperti yang diasumsikan:

// A and B are mutable reference types

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

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

Maka mudah untuk mendapatkan sintaks yang bagus dengan konversi yang ditentukan pengguna secara implisit (tidak perlu metode ekstensi atau yang serupa):

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

Ilustrasi penggunaan:

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

The InitializeMetode menunjukkan bagaimana Anda dapat bekerja dengan Adaptertanpa peduli apakah itu adalah AatauB atau sesuatu yang lain. Invokasi dari Initializemetode ini menunjukkan bahwa kita tidak memerlukan gips (yang terlihat) .AsProxy()atau sejenisnya untuk mengolah beton Aatau Bsebagai Adapter.

Pertimbangkan jika Anda ingin melempar ArgumentNullException konversi yang ditentukan pengguna jika argumen yang diteruskan adalah referensi nol, atau tidak.

Jeppe Stig Nielsen
sumber