我们在定义一个实体的时候,一般是不希望对外暴露其内部过多的成员信息的。尤其是一些集合信息,因为这些集合信息如果对外暴露不慎,就会破坏封装性,从而使得外部对象能够对其进行一些破坏性的修改。所以对外我们一般返回只读集合,这个问题在之前的文章不要对外公开泛型List成员中提到过。

问题的产生

下面以我们购物中的购物车为例来说明:

    public class Cart
    {
        private List<ProducItem> ProductItemsCollection;

        public Cart()
        {
            ProductItemsCollection = new List<ProducItem>();
        }
        /// <summary>
        /// 向购物车添加商品
        /// </summary>
        /// <param name="product"></param>
        /// <param name="quantity"></param>
        public virtual void AddItem(Product product, int quantity)
        {
            ProducItem line = ProductItemsCollection.FirstOrDefault(x => x.Product.ProductID == product.ProductID);
            if (line != null)
            {
                line.Quantity += quantity;
            }
            else
            {
                ProductItemsCollection.Add(new ProducItem { Product = product, Quantity = quantity });
            }
        }

        /// <summary>
        /// 移除购物车里的商品
        /// </summary>
        /// <param name="product"></param>
        public virtual void RemoveItem(Product product)
        {
            ProductItemsCollection.RemoveAll(x => x.Product.ProductID == product.ProductID);
        }

        /// <summary>
        /// 计算购物车里商品的总金额
        /// </summary>
        /// <returns></returns>
        public virtual decimal ComputerTotalValue() => ProductItemsCollection.Sum(x => x.Product.Price * x.Quantity);

        /// <summary>
        /// 清空购物车
        /// </summary>
        public virtual void Clear() => ProductItemsCollection.Clear();

        /// <summary>
        /// 返回购物车里所有商品
        /// </summary>
        public virtual List<ProducItem> ProductItems => ProductItemsCollection;
    }

    public class ProducItem
    {
        public int ID { get; set; }
        public Product Product { get; set; }
        public int Quantity { get; set; }
    }

     以上作为购物车的模型来说,是存在问题的。最大的问题就是他对外直接暴露了ProductItems对象,这样会有两个隐患:

     首先,对外部调用者来说,可以直接对ProductItems执行添加、删除操作,而不需要去调用RemoveLine,AddItem来改变状态,这种方式严重破坏了封装性。

     其次,对外部调用者来说,可以直接改变ProductItems的内部元素的某个值,比如通过索引访问单个ProductItem对象,改变其字段。

        [Fact]
        public void CanModifyCartProductItem()
        {
            Product p1 = new Product { ProductID = 1, Name = "P1", Price = 20.5M };
            Product p2 = new Product { ProductID = 2, Name = "P2", Price = 10.25M };
            Cart c = new Cart();
            c.AddItem(p1, 1);
            c.AddItem(p2, 3);
            
            foreach (var item in c.ProductItems)
            {
                item.Quantity = 5;
            }
            int p1Quantity= c.ProductItems.ElementAt(0).Quantity;
            int p2Quantity = c.ProductItems.ElementAt(1).Quantity;
            Assert.Equal(5, p1Quantity);
            Assert.Equal(5, p2Quantity);
        }

    上面的单元测试代码是可以测试通过的,可以看到,外部对象可以直接通过访问ProductItems集合,遍历内部对象然后修改其数量,而不是通过AddItem添加商品来增加数量,对这种修改,对象并没有得到通知。

解决方法

     上述存在的两个问题,需要分别解决。

     首先要限制外部对象直接对List集合进行修改操作,这有一些解决方法,第一种方法是自定义集合,这种方法在不要对外公开泛型List成员这篇文章里有讲解。通过自定义集合实现ICollection接口,我们可以重写集合的Insert,Set,Remove,Clear等事件,并在对象内部注册这些事件,当外部引用对对象返回的自定义集合进行操作的时候,对象内部会得到通知,然后去更改相对应的状态。第二种方法是,不要返回List对象,返回现成的对外部来说不可修改的对象,比如IEnumerable,IReadOnlyCollection等。比如:

public virtual IEnumerable<ProductItem> ProductItems => ProductItemsCollection;

或者

 public virtual IReadOnlyCollection<ProductItem> ProductItems => ProductItemsCollection.AsReadOnly();

需要注意的是,如果将只读属性定义为IReadOnlyCollection,在反序列化时,可能会无法初始化,从而报错,解决方法是,在其私有字段上加上标记:

 [JsonProperty(PropertyName = "Products")]
 private List<ProductItem> ProductItemsCollection;

这样,外部引用获取到ProductItems后没有办法对其进行添加或删除操作了。

    现在,第一个问题解决了,但是第二个问题依然存在,外部引用,可以通过索引,或者枚举的方法,获得内部集合里面的元素,并对其属性进行修改。可以看到,即使按照上述修改,上面的单元测试代码依然能沟通过。要解决内部对象修改的问题,有两种方法,一种是将内部集合的对象改为只读。比如,将ProductItem的对象的属性改为只读,这样,即使外部对象能够访问内部元素,也无法修改其内部状态,这也是最简单的一种方式:

    public class ProductItem
    {
        public int ID { get; }
        public Product Product { get; }
        public int Quantity { get; }

        public ProductItem(int id, Product product, int quantity)
        {
            ID = id;
            Product = product;
            Quantity = quantity;
        }
    }

     另外,还有一种办法是,我们在返回集合字段的时候,新建一个集合,而不是传递对原集合的引用,这里就涉及到了对集合的深拷贝和浅拷贝问题,这里非常容易犯错,比如:

 public virtual IEnumerable<ProductItem> ProductItems
 {
       get
       {
            List<ProductItem> items = new List<ProductItem>();
            items.AddRange(ProductItems);
            return items;
        }
}

    其实这是一种浅拷贝,返回的新的集合对象,内部元素仍然是指向集合内部的元素,我们只是反馈了一个新的“壳”,里面的内容还是指向之前的老的对象。如果运行上述的单元测试,会发现仍然能够通过测试。

     我们这里需要的是一个“船新”的集合,包括里面的元素。所以需要对ProductItem进行Clone,一般做法是,对ProductItem实现IClonable接口,但在.NET Core里面,该接口被弃用了,因为这在之前是被认为是一个错误的设计,因为它没有说明是“深拷贝”还是“浅拷贝”,容易引起误解。这里我们自己定义一个DeepClone方法。

    public class ProductItem
    {
        public int ID { get; private set; }
        public Product Product { get; private set; }
        public int Quantity { get; set; }

        public ProductItem(int id, Product product, int quantity)
        {
            ID = id;
            Product = product;
            Quantity = quantity;
        }
        public ProductItem DeepClone()
        {
            return new ProductItem(ID, Product, Quantity);
        }
    }

   然后,在返回的ProductItems 这里,改为深拷贝:

public virtual IEnumerable<ProductItem> ProductItems => ProductItemsCollection.ConvertAll(x => x.DeepClone());

   现在,在运行上面的单元测试,会发现失败,表示达到了我们的目的了。现在我们修改一下单元测试,让他通过:

[Fact]
public void CanNotModifyCartProductItem()
{
    Product p1 = new Product { ProductID = 1, Name = "P1", Price = 20.5M };
    Product p2 = new Product { ProductID = 2, Name = "P2", Price = 10.25M };
    Cart c = new Cart();
    c.AddItem(p1, 1);
    c.AddItem(p2, 3);
            
         
    foreach (var item in c.ProductItems)
    {
        item.Quantity = 5;
    }
    int p1Quantity= c.ProductItems.ElementAt(0).Quantity;
    int p2Quantity = c.ProductItems.ElementAt(1).Quantity;
    Assert.Equal(1, p1Quantity);
    Assert.Equal(3, p2Quantity);
}

      这种方法,我们要考虑深拷贝带来的性能影响,对于大的引用对象,返回针对引用对象的引用在很多时候能够避免方法传递时的对象复制,能提升性能,但是在有些时候也会破坏封装性。

总结

    写代码的时候,要考虑对象的封装性,不要将不必要的内部成员或者状态暴露到外面,这样可能会破坏一致性,正如《程序员修炼之道》一文所说的,我们要定义一个“害羞”的,“犹抱琵琶半遮面”的对象。在写代码的时候不能“大手大脚”,每个地方都需要仔细考虑。本文就讨论了对象里如何对外返回集合这一常见场景中存在的问题,并给出了对应的解决方法。

 

参考资料


https://stackoverflow.com/questions/536349/why-no-icloneablet 

https://www.cnblogs.com/sirkevin/p/3914606.html

https://www.yycoding.xyz/post/2014/5/23/do-not-expose-generic-lists