C # Generics - Làm thế nào để tránh phương pháp dư thừa?


28

Giả sử tôi có hai lớp trông như thế này (khối mã đầu tiên và vấn đề chung có liên quan đến C #):

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

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

Các lớp này không thể được thay đổi theo bất kỳ cách nào (chúng là một phần của hội đồng bên thứ 3). Do đó, tôi không thể làm cho chúng thực hiện cùng một giao diện hoặc kế thừa cùng một lớp mà sau đó sẽ chứa IntProperty.

Tôi muốn áp dụng một số logic trên thuộc IntPropertytính của cả hai lớp và trong C ++, tôi có thể sử dụng một lớp mẫu để thực hiện điều đó khá dễ dàng:

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

};

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

Và sau đó tôi có thể làm một cái gì đó như thế này:

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

Bằng cách đó tôi có thể tạo một lớp nhà máy chung duy nhất hoạt động cho cả ClassA và ClassB.

Tuy nhiên, trong C #, tôi phải viết hai lớp với hai wheremệnh đề khác nhau mặc dù mã cho logic hoàn toàn giống nhau:

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

Tôi biết rằng nếu tôi muốn có các lớp khác nhau trong wheremệnh đề, chúng cần phải liên quan, nghĩa là kế thừa cùng một lớp, nếu tôi muốn áp dụng cùng một mã cho chúng theo nghĩa mà tôi đã mô tả ở trên. Chỉ là rất khó chịu khi có hai phương pháp hoàn toàn giống nhau. Tôi cũng không muốn sử dụng sự phản chiếu vì các vấn đề hiệu suất.

Ai đó có thể đề xuất một số cách tiếp cận trong đó điều này có thể được viết theo một cách thanh lịch hơn?


3
Tại sao bạn lại sử dụng thuốc generic cho việc này? Không có gì chung chung về hai chức năng đó.
Luaan

1
@Luaan Đây là một ví dụ đơn giản về một biến thể của mẫu nhà máy trừu tượng. Hãy tưởng tượng có hàng tá các lớp kế thừa ClassA hoặc ClassB và ClassA và ClassB là các lớp trừu tượng. Các lớp kế thừa không mang thêm thông tin và cần phải được khởi tạo. Thay vì viết một nhà máy cho mỗi người trong số họ, tôi đã chọn sử dụng thuốc generic.
Vladimir Stokic

6
Chà, bạn có thể sử dụng sự phản chiếu hoặc động lực nếu bạn tự tin rằng họ sẽ không phá vỡ nó trong các phiên bản tương lai.
Casey

Đây thực sự là khiếu nại lớn nhất của tôi về thuốc generic là nó không thể làm được điều này.
Joshua

1
@Joshua, tôi nghĩ vấn đề này nhiều hơn với các giao diện không hỗ trợ "gõ vịt".
Ian

Câu trả lời:


49

Thêm giao diện proxy (đôi khi được gọi là bộ điều hợp , đôi khi có sự khác biệt tinh tế), triển khai LogicToBeAppliedvề mặt proxy, sau đó thêm một cách để tạo một phiên bản của proxy này từ hai lambdas: một cho thuộc tính get và một cho tập hợp.

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

Bây giờ, bất cứ khi nào bạn cần vượt qua trong một IProxy nhưng có một thể hiện của các lớp bên thứ ba, bạn có thể chỉ cần vượt qua trong một số 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

Ngoài ra, bạn có thể viết các trình trợ giúp đơn giản để xây dựng các phiên bản LamdaProxy từ các phiên bản A hoặc B. Chúng thậm chí có thể là các phương thức mở rộng để cung cấp cho bạn kiểu "trôi chảy":

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

Và bây giờ việc xây dựng các proxy trông như thế này:

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

Đối với nhà máy của bạn, tôi sẽ xem liệu bạn có thể cấu trúc lại nó thành phương thức nhà máy "chính" chấp nhận IProxy và thực hiện tất cả logic trên nó và các phương thức khác chỉ truyền vào new A().Proxied()hoặc 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;
    }
}

Không có cách nào để làm tương đương với mã C ++ của bạn trong C # vì các mẫu C ++ dựa trên kiểu gõ cấu trúc . Miễn là hai lớp có cùng tên phương thức và chữ ký, trong C ++, bạn có thể gọi phương thức đó một cách khái quát trên cả hai lớp. C # có kiểu gõ danh nghĩa - tên của một lớp hoặc giao diện là một phần của kiểu của nó. Do đó, các lớp ABkhông thể được đối xử như nhau trong bất kỳ khả năng nào trừ khi mối quan hệ "là" rõ ràng được xác định thông qua kế thừa hoặc thực hiện giao diện.

Nếu bản tóm tắt của việc thực hiện các phương thức này trên mỗi lớp quá nhiều, bạn có thể viết một hàm lấy một đối tượng và xây dựng một cách phản xạLambdaProxy bằng cách tìm kiếm một tên thuộc tính cụ thể:

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

Điều này thất bại rất nhiều khi đưa ra các đối tượng không đúng loại; sự phản chiếu vốn đã giới thiệu khả năng thất bại mà hệ thống loại C # không thể ngăn chặn. May mắn thay, bạn có thể tránh phản xạ cho đến khi gánh nặng bảo trì của người trợ giúp trở nên quá lớn vì bạn không bắt buộc phải sửa đổi giao diện IProxy hoặc triển khai LambdaProxy để thêm đường phản chiếu.

Một phần lý do công việc này LambdaProxylà "chung chung tối đa"; nó có thể điều chỉnh bất kỳ giá trị nào thực hiện "tinh thần" của hợp đồng IProxy bởi vì việc triển khai LambdaProxy hoàn toàn được xác định bởi các hàm getter và setter đã cho. Nó thậm chí hoạt động nếu các lớp có các tên khác nhau cho thuộc tính hoặc các loại khác nhau có thể biểu thị một cách hợp lý và an toàn như ints hoặc nếu có cách nào đó để ánh xạ khái niệm Propertyđược cho là đại diện cho bất kỳ tính năng nào khác của lớp. Các chỉ định được cung cấp bởi các chức năng cung cấp cho bạn sự linh hoạt tối đa.


Một cách tiếp cận rất thú vị và chắc chắn có thể được sử dụng để gọi các hàm, nhưng nó có thể được sử dụng cho nhà máy, nơi tôi thực sự cần tạo các đối tượng của ClassA và ClassB?
Vladimir Stokic

@VladimirStokic Xem các chỉnh sửa, tôi đã mở rộng một chút về điều này
Jack

chắc chắn phương pháp này vẫn yêu cầu bạn ánh xạ rõ ràng thuộc tính cho từng loại với khả năng xảy ra lỗi thời gian chạy nếu chức năng ánh xạ của bạn bị lỗi
Ewan

Thay thế cho ReflectiveProxier, bạn có thể xây dựng proxy bằng dynamictừ khóa không? Dường như với tôi bạn sẽ có những vấn đề cơ bản tương tự (ví dụ như các lỗi chỉ bắt gặp khi chạy), nhưng cú pháp và khả năng bảo trì sẽ đơn giản hơn nhiều.
Bobson

1
@Jack - Đủ công bằng. Tôi đã thêm câu trả lời của riêng tôi demo nó. Đây là một tính năng rất hữu ích, trong một số trường hợp hiếm hoi (như thế này).
Bobson

12

Dưới đây là một phác thảo về cách sử dụng bộ điều hợp mà không cần kế thừa từ A và / hoặc B, với khả năng sử dụng chúng cho các đối tượng A và B hiện có:

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
}

Tôi thường thích loại bộ điều hợp đối tượng này hơn các proxy lớp, chúng tránh các vấn đề xấu mà bạn có thể gặp phải với sự kế thừa. Ví dụ, giải pháp này sẽ hoạt động ngay cả khi A và B là các lớp niêm phong.


Tại sao new int Property? bạn không che giấu bất cứ điều gì.
Pinkfloydx33

@ Pinkfloydx33: chỉ là một lỗi đánh máy, đã thay đổi nó, cảm ơn.
Doc Brown

9

Bạn có thể thích nghi ClassAClassBthông qua một giao diện chung. Bằng cách này, mã của bạn LogicAToBeAppliedvẫn giữ nguyên. Không khác nhiều so với những gì bạn có mặc dù.

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 sử dụng Mẫu bộ điều hợp là giải pháp OOP truyền thống ở đây. Đó là nhiều hơn một bộ chuyển đổi hơn một proxy vì chúng ta thích ứng với A, Bloại để một giao diện chung. Ưu điểm lớn là chúng ta không phải lặp lại logic chung. Nhược điểm là logic bây giờ khởi tạo trình bao bọc / proxy thay vì loại thực tế.
amon

5
Vấn đề với giải pháp này là bạn không thể đơn giản lấy hai đối tượng loại A và B, chuyển đổi chúng bằng cách nào đó thành AProxy và BProxy, sau đó áp dụng LogicToBeApplied cho chúng. Vấn đề này có thể được giải quyết bằng cách sử dụng tổng hợp thay vì kế thừa (tương ứng thực hiện các đối tượng proxy không phải bằng cách xuất phát từ A và B, mà bằng cách tham chiếu đến các đối tượng A và B). Lại một ví dụ về việc sử dụng sai quyền thừa kế gây ra vấn đề như thế nào.
Doc Brown

@DocBrown Làm thế nào mà đi trong trường hợp cụ thể này?
Vladimir Stokic

1
@Jack: các loại giải pháp này có ý nghĩa khi LogicToBeAppliedcó độ phức tạp nhất định và không nên lặp lại ở hai vị trí trong cơ sở mã trong mọi trường hợp. Sau đó, mã soạn sẵn bổ sung thường không đáng kể.
Doc Brown

1
@Jack Sự dư thừa ở đâu? Hai lớp không có giao diện chung. Bạn tạo giấy gói mà làm có một giao diện chung. Bạn sử dụng giao diện chung đó để thực hiện logic của bạn. Nó không giống như sự dư thừa tương tự không tồn tại trong mã C ++ - nó chỉ ẩn sau một chút việc tạo mã. Nếu bạn cảm thấy mạnh mẽ về những thứ trông giống nhau, mặc dù chúng không giống nhau, bạn luôn có thể sử dụng T4 hoặc một số hệ thống tạo khuôn mẫu khác.
Luaan

8

Phiên bản C ++ chỉ hoạt động vì các mẫu của nó sử dụng vịt tĩnh gõ gõ - bất cứ thứ gì biên dịch miễn là loại cung cấp đúng tên. Nó giống như một hệ thống vĩ mô. Hệ thống generic của C # và các ngôn ngữ khác hoạt động rất khác nhau.

Câu trả lời của devnull và Doc Brown cho thấy cách sử dụng mẫu bộ điều hợp để giữ cho thuật toán của bạn chung và vẫn hoạt động trên các loại tùy ý với một vài hạn chế. Đáng chú ý, bạn hiện đang tạo một loại khác với thực tế bạn muốn.

Với một chút mánh khóe, có thể sử dụng chính xác loại dự định mà không có bất kỳ thay đổi nào. Tuy nhiên, bây giờ chúng ta cần trích xuất tất cả các tương tác với loại mục tiêu vào một giao diện riêng. Ở đây, các tương tác này là xây dựng và chuyển nhượng tài sản:

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

Trong một diễn giải OOP, đây sẽ là một ví dụ về mẫu chiến lược , mặc dù pha trộn với khái quát.

Sau đó chúng tôi có thể viết lại logic của bạn để sử dụng các tương tác này:

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

Các định nghĩa tương tác sẽ trông như sau:

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

Nhược điểm lớn của phương pháp này là lập trình viên cần viết và vượt qua một thể hiện tương tác khi gọi logic. Điều này khá giống với các giải pháp dựa trên mô hình bộ điều hợp, nhưng tổng quát hơn một chút.

Theo kinh nghiệm của tôi, đây là lần gần nhất bạn có thể nhận được các hàm mẫu trong các ngôn ngữ khác. Các kỹ thuật tương tự được sử dụng trong Haskell, Scala, Go và Rust để thực hiện các giao diện bên ngoài định nghĩa kiểu. Tuy nhiên, trong các ngôn ngữ này, trình biên dịch bước vào và chọn đối tượng tương tác chính xác để bạn không thực sự thấy đối số phụ. Điều này cũng tương tự như các phương thức mở rộng của C #, nhưng không bị hạn chế đối với các phương thức tĩnh.


Cách tiếp cận thú vị. Không phải là lựa chọn đầu tiên của tôi, nhưng tôi đoán nó có thể có một số lợi thế khi viết một khung hoặc một cái gì đó tương tự.
Doc Brown

8

Nếu bạn thực sự muốn thận trọng với gió, bạn có thể sử dụng "động" để làm cho trình biên dịch xử lý tất cả sự khó chịu phản xạ cho bạn. Điều này sẽ dẫn đến lỗi thời gian chạy nếu bạn chuyển một đối tượng cho SetSomeProperty không có thuộc tính có tên là 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ác câu trả lời khác xác định chính xác vấn đề và cung cấp các giải pháp khả thi. C # không (nói chung) hỗ trợ "gõ vịt" ("Nếu nó đi như vịt ..."), vì vậy không có cách nào để buộc bạn ClassAClassBcó thể hoán đổi cho nhau nếu chúng không được thiết kế theo cách đó.

Tuy nhiên, nếu bạn đã sẵn sàng chấp nhận rủi ro về lỗi thời gian chạy, thì có một câu trả lời dễ dàng hơn là sử dụng Reflection.

C # có dynamictừ khóa hoàn hảo cho các tình huống như thế này. Nó nói với trình biên dịch "Tôi sẽ không biết loại này là gì cho đến khi thời gian chạy (và có thể thậm chí không sau đó), vì vậy cho phép tôi làm bất cứ điều gì với nó".

Sử dụng đó, bạn có thể xây dựng chính xác chức năng bạn muốn:

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

Lưu ý việc sử dụng statictừ khóa là tốt. Điều đó cho phép bạn sử dụng điều này như:

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

Không có ý nghĩa hiệu suất hình ảnh lớn của việc sử dụng dynamic, cách có một lần nhấn (và thêm độ phức tạp) của việc sử dụng Reflection. Lần đầu tiên mã của bạn đạt được cuộc gọi động với một loại cụ thể sẽ có một lượng chi phí nhỏ , nhưng các cuộc gọi lặp lại sẽ nhanh như mã tiêu chuẩn. Tuy nhiên, bạn sẽ nhận được RuntimeBinderExceptionnếu bạn cố gắng vượt qua thứ gì đó không có tài sản đó và không có cách nào tốt để kiểm tra trước thời hạn. Bạn có thể muốn xử lý cụ thể lỗi đó một cách hữu ích.


Điều này có thể chậm, nhưng thường mã chậm không phải là vấn đề.
Ian

@Ian - Điểm tốt. Tôi đã thêm một chút về hiệu suất. Nó thực sự không tệ như bạn nghĩ, miễn là bạn sử dụng lại các lớp giống nhau ở cùng một điểm.
Bobson

Hãy nhớ trong các mẫu C ++ thậm chí không có các phương thức ảo!
Ian

2

Bạn có thể sử dụng sự phản chiếu để kéo ra tài sản theo tên.

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

Rõ ràng bạn có nguy cơ lỗi thời gian chạy với phương pháp này. Đó là những gì C # đang cố gắng ngăn bạn làm.

Tôi đã đọc ở đâu đó rằng một phiên bản C # trong tương lai sẽ cho phép bạn vượt qua các đối tượng như một giao diện mà chúng không kế thừa nhưng lại khớp. Mà cũng sẽ giải quyết vấn đề của bạn.

(Tôi sẽ thử và tìm hiểu bài viết)

Một phương pháp khác, mặc dù tôi không chắc rằng nó tiết kiệm cho bạn bất kỳ mã nào, sẽ là phân lớp cả A và B và cũng thừa hưởng Giao diện với IntProperty.

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

public class A2 : A, IIntProp {}

public class B2 : B, IIntProp {}

Khả năng xảy ra lỗi thời gian chạy và các vấn đề về hiệu năng là những lý do khiến tôi không muốn phản ánh. Tôi, tuy nhiên, rất thích đọc bài báo mà bạn đề cập trong câu trả lời của bạn. Nhìn về phía trước để đọc nó.
Vladimir Stokic

1
chắc chắn bạn có nguy cơ tương tự với giải pháp c ++ của bạn?
Ewan

4
@Ewan no, c ++ kiểm tra thành viên vào thời gian biên dịch
Caleth

Phản xạ có nghĩa là các vấn đề tối ưu hóa và (rất quan trọng hơn) khó gỡ lỗi các lỗi thời gian chạy. Kế thừa và giao diện chung có nghĩa là khai báo một lớp con trước thời hạn cho mỗi một trong số các lớp này (không có cách nào để tạo một ẩn danh tại chỗ) và không hoạt động nếu chúng không sử dụng cùng một tên thuộc tính mỗi lần.
Jack

1
@Jack có những nhược điểm, nhưng hãy xem xét rằng sự phản chiếu được sử dụng rộng rãi trong Mappers, serial serial, Dependency Injection framework, v.v. và mục tiêu là thực hiện với số lượng sao chép mã ít nhất
Ewan

0

Tôi chỉ muốn sử dụng các implicit operatorchuyển đổi cùng với cách tiếp cận của đại biểu / lambda trong câu trả lời của Jack. ABnhư được giả định:

// A and B are mutable reference types

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

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

Sau đó, thật dễ dàng để có được cú pháp đẹp với các chuyển đổi ngầm định do người dùng xác định (không cần các phương thức mở rộng hoặc tương tự cần thiết):

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

Minh họa sử dụng:

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

Các Initializephương pháp cho thấy cách bạn có thể làm việc với Adaptermà không quan tâm về việc liệu nó là một Ahoặc một Bhay cái gì khác. Các yêu cầu của Initializephương pháp cho thấy rằng chúng ta không cần bất kỳ vật đúc (có thể nhìn thấy) nào .AsProxy()hoặc tương tự để xử lý bê tông Ahoặc Bnhư một Adapter.

Xem xét nếu bạn muốn đưa ra một ArgumentNullExceptionchuyển đổi do người dùng xác định nếu đối số được truyền là tham chiếu null hoặc không.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.