容器与封装

作者: _袁英杰_ | 来源:发表于2016-07-02 13:11 被阅读935次

    在实际项目中,经常能够看到容器被当作参数,在不同的对象间传递。这样做有什么问题?

    缺乏内聚性

    在进一步讨论之前,我们先来看看下面两个表达式之间有何区别?

    int value; 
    std::list<int> values; 
    

    经常得到的答案是:前者是一个primitive的数据,后者是一个对象。对于前者,你只能执行基本的数值演算;而后者的类型std::list<int>是一个类,你可以调用它的方法,比如:

    values.push_back(5);
    

    这个答案并没有什么错。那我们再来看一个问题:下面两个表达式的区别在哪里?

    Object object;
    std::list<Object> objects;
    

    对于这个问题,之前的答案就不再有效。因为在这个例子中,两者都是对象。其不同之处在于,对Object对象的方法调用,是对一个具体的业务对象的操作;而后者却是对容器对象的操作。

    现在,我们将问题变为:这两个例子中,前后两个表达式的共同差异是什么?

    如果你比较敏锐,应该已经得到答案:前者代表一个数据(对象),后者代表一组数据(对象)。

    所以,虽然容器本身是一个对象,但更本质地,它代表着一组数据,围绕着这组数据的业务逻辑,容器对象本身并没有涉及。

    所以,直接访问一个容器,而不是将容器封装在一个业务对象里;这和直接操作一个数据,而不是将数据封装在一个抽象数据类型里,本质上没有任何区别。它们都违反了数据和操作它的行为应该放在一起的高内聚原则。

    缺乏稳定性

    现在,我们再问一个问题:下面的三个表达式的共同之处是什么?

    Object objects[100];
    std::vector<Object> objects;
    std::list<Object>   objects;
    

    答案很简单:都代表多个Object对象的集合。它们之间的实现技术上虽然不同,但从抽象层面上,这三个实现方式所要表达的概念并无任何不同。

    实现技术可以随着约束的变化而变化,但只要用户的抽象需求没有发生变化,用户的代码就不应该受到具体实现技术变化的影响。

    因此,直接让用户访问容器对象,不仅仅违反了高内聚的原则,还违反了“向着稳定的方向依赖”原则。

    对容器进行封装

    基于上述的讨论,我们可以得出如下结论:当系统中存在一个集合概念时,应考虑包含这个集合概念的单一概念是什么,并根据这个单一概念对集合进行封装。

    比如:一个班包含许多学生。糟糕的做法是:

    typedef std::list<Student> SchoolClass; 
    

    当在另外一个对象需要计算一个班的平均成绩时,就会出现类似于下面的代码:

    struct Foo 
    { 
      void f(const SchoolClass& cls) 
      { 
        unsigned int averageScore = getAverageScoreOfClass(cls); 
        
        // ... 
      }
       
    private: 
      unsigned int getAverageScoreOfClass(const SchoolClass& cls) 
      { 
        unsigned int totalScore = 0;    
        for( SchoolClass::const_iterator i=cls.begin(); i != cls.end(); ++i)
        {
          totalScore += (*i).getScore();
        }
        
        return totalScore/cls.size(); 
      } 
      // ... 
    };
    

    而一个合理的做法则是:

    struct SchoolClass 
    { 
      unsigned int getAverageScore() const 
      { 
        // ... 
      } 
      // ... 
      
    private:  
      std::list<Student> students; 
    };
     
    struct Foo 
    { 
      void f(const SchoolClass& cls) 
      { 
        unsigned int averageScore = cls.getAverageScore(); 
        // ... 
      } 
      // ... 
    };
    

    如果有一天,设计者认为使用定长数组是更好的选择(因为std::list有可能因为内存问题而带来的不确定性),那么所有的修改都被控制在SchoolClass内部,对于Foo,以及任何其它SchoolClass的客户都毫无影响(局部化影响)。

    多级容器

    另外,在实际项目中,经常能够看到类似于下面的定义:

    typedef std::map<std::string, std::map<std::string, std::string> 
                 > ConfigFile;
    

    这还算轻微的。事实上,在我经历过的项目中,三级甚至四级容器也并不罕见。

    相对于单级容器,多级容器带来的问题更多:这样复杂的数据结构定义本身就非常晦涩,而其处理代码也往往互相交织在一起,不仅难以理解,还极其脆弱:其中任何一个级别的容器发生变化都会给整个数据结构的处理代码带来影响。

    比如,上述数据结构完全可以改变为:

    typedef std::map<std::string, std::list<std::pair<std::string, std::string> > 
                 > ConfigFile;
    

    对于多级容器,其处理方法和单级容器的方法并没有什么两样:将每一级容器都进行封装。比如,对于刚才这个例子,至少可以进行类似于下面的封装:

    struct ConfigFile 
    { 
      // ... 
    private:  
      std::map<std::string, ConfigSection> sections; 
    };
     
    struct ConfigSection 
    { 
      // ... 
    private:  
      std::map<std::string, std::string> items; 
    }; 
    

    用意不明的数据子集

    当一个数据集合被封装在一个类中之后,对于这个数据集合的需求可能变化非常剧烈。比如,客户代码可以基于各种各样的目的,从数据集合中过滤出一个数据子集,并对这个数据子集执行自己所需的操作。

    如果将所有客户的意图,都堆积在数据集合所在的类中实现,将会造成这个类极其不稳定,也容易造成上帝类。同时,也会降低客户代码的内聚度。

    这种情况下,数据集合类提供查询接口,由客户自定义一个过滤条件,数据集合类根据客户自定义的过滤条件,得到客户所需的数据子集,由客户代码对数据子集定义所需的操作,反而是个更好的选择。

    对于数据集合类而言,这些数据子集的语意是不明的,因为客户才知道它的用途。所以,如果需要对这些数据子集进行封装的话,也应该是客户的责任。如果客户将数据子集封装为语意明确的类,并将这个类作为输出参数传递给数据集合类的话,既会造成数据集合类对这些数据子集类型的依赖,同时仍然会造成数据集合类接口的不稳定。

    所以,设计者们往往选择给数据集合类提供类似与下面的接口与实现:

    struct SchoolClass 
    { 
      void getStudentsByFilter 
        ( const Filter& filter // 输入参数:过滤器 
        , std::list<Student>& result // 输出参数:查询结果 
        ) const 
      { 
        for( SchoolClass::const_iterator i=cls.begin() ; i != cls.end(); ++i)
        {      
          if(filter.matches(*i))
          { 
            result.push_back(*i);
          }
        } 
      } 
      // ... 
    
    private:  
      std::list<Student> students; 
    };
    

    这样的方法,几乎可以保证数据集合类接口和实现的稳定。之所以说“几乎”,是因为std::list作为双方交换数据的契约,仍然过于具体。一旦因为某种原因发生变化,则双方代码都会受到影响。

    但是,我们之前已经得出过结论:std::list虽然很具体,但也不能对其进行业务层面的封装。我们似乎陷入了黔驴技穷的处境。

    5 Why分析法告诉我们,如果我们多问几个为什么,就能找到更加稳定的抽象。

    客户在拿到数据子集之后,一定有自己的意图,我们如果让接口反映的是用户自己的意图,而不是数据子集这么具体的实现细节,那么数据子集将会变成一个无用的中间层。

    那客户的意图是什么呢?不知道。但我们有多态这门进行抽象的强大武器,借助于它,客户的确切意图对我们便不再重要。

    所以,我们可以将上述代码修改为:

    struct Visitor 
    { 
      virtual void visit(const Student& student) = 0; 
      virtual ~Visitor {} 
    };
     
    struct SchoolClass 
    { 
      void visitStudentsByFilter 
         ( const Filter& filter  // 输入参数:过滤器
         , Visitor&      visitor // 输入参数:对过滤结果的处理 
         ) const 
      {
        for( SchoolClass::const_iterator i = cls.begin() ; i != cls.end(); ++i) 
        {
          if(filter.matches(*i)) 
          { 
            visitor.visit(*i);
          }
        } 
      } 
      // ... 
    private:  
      std::list<Student> students; 
    };
    

    这样的实现方式,帮助我们更加直接的满足客户的意图。这不仅让双方的代码更加稳定,在很多场合下,由于客户并不需要存储查询的结果,绕开std::list这样的数据集合,还可以提高性能,并降低内存管理方面的负担。

    比如,一个客户想过滤出所有及格的学生,只是为了统计及格学生的数量,那么它就可以将Visitor实现为:

    unsigned int Foo::getNumOfPassStudents(const SchoolClass& cls) const 
    { 
      struct PassStudentFilter : Filter 
      { 
        bool matches(const Student& student) const 
        {
          return student.isPass(); 
        }
      } filter; 
    
      struct PassStudentsCounter : Visitor 
      { 
        PassStudentsCounter() : numOfPassStudents(0) {} 
        
        void visit(const Student& student) { numOfPassStudents++; }
        
        unsigned int numOfPassStudents; 
      } counter; 
    
      cls.visitStudentsByFilter(filter, counter); 
    
      return counter.numOfPassStudents; 
    } 
    

    通过这个实现,我们注意到一个重要事实:Filter是不必要的,因为客户可以在Visitor里自己进行过滤。所以,我们将之前数据集合类的实现修改为简化版本的访问者模式(由于没有多种类型的元素,所以不需要双重派发)。而这是一个更加通用的抽象,借助于它,可以简化双方的实现。

    struct SchoolClass 
    { 
      void accept(Visitor& visitor) const 
      { 
        for( SchoolClass::const_iterator i=cls.begin() ; i != cls.end(); ++i) 
        {
          visitor.visit(*i);
        } 
      } 
      // ... 
    private:  
      std::list<Student> students; 
    };
    

    而之前的客户代码也得到简化:

    unsigned intFoo::getNumOfPassStudents(const SchoolClass& cls) const 
    { 
      struct PassStudentsCounter : Visitor 
      { 
        PassStudentsCounter() : numOfPassStudents(0) {} 
        void visit(const Student& student)
        { 
          if(student.isPass()) numOfPassStudents++; 
        } 
        unsigned int numOfPassStudents; 
      } counter; 
      
      cls.accept(counter);
    
      return counter.numOfPassStudents; 
    }
    

    而对于确实需要保存下来过滤结果的客户,仍然可以轻松达到目标:

    struct Bar : private Visitor 
    { 
      void savePassedStudents(const SchoolClass& cls) 
      { 
        cls.accept(*this); 
      } 
      
      // ... 
    
    private:  // 对 visit 方法的实现 
    
      void visit(const Student& student) 
      { 
        if(student.isPass()) passedStudents.push_back(student); 
      } 
      
    private:
      // 注意,这不是 std::list,而是用户根据自己需要而采用的数据结构 
      std::vector<Student> passedStudents;
      // ... 
    };
    

    在这个实现中,使用了私有继承。关于其用法的详细讨论,请参考《Virtues of Bastard》

    总结

    本文探讨了直接暴露容器所带来的问题,以及如何进行封装,以提高可维护性。关于封装,请参考《类与封装》

    相关文章

      网友评论

      • 武可:在函数式编程中,似乎常有把一个集合作为参数的情况。不知道袁创怎么看?
        _袁英杰_:@武可 如果是那样简单,就不值得深谈了…:smile:
        武可:@_袁英杰_ 莫不是要喷函数式太依赖原始类型,很傻很天真? :smile:
        _袁英杰_:@武可 这是一个很值得深谈的话题。我也很早就想好好谈谈了。等我文章。
      • 31069314f47a:经常纠结如下情况:
        当用户需要判断指定id的学生是否及格, 这个函数是由集合提供还是让用户获取到指定的student,再调用student的ispass()函数,如果由集合提供,用户只需要提供学生id即可,似乎封装性更好,但是似乎集合容易变上帝类,因为别的用户可能有需要判断学生是男是女这样的需求, 如果采用visitor,用户需要把业务逻辑都放到visit()中实现,而且会需要遍历所有学生,对性能有一点影响。求教怎么选择
        _袁英杰_:@31069314f47a 对于按照id去查询某个学生,这一定不是一个visitor实现,而是非常明确的基于repository的retrieve操作。因而,这类需求的实现,应该是首先到repository中根据entity id找到entity, 然后再调用entity的接口。如果entity的行为很多且根据不同上下文有不同的行为,为了避免entity变为god class, 可使用《小类,大对象》的方式解决。具体可参考《小类,大对象》那篇文章。
      • _张逸_:我曾经总结过所谓OO的三要素:封装(Encapsulation)、继承(Inheritance)、多态(Polymorphism)。

        封装这个概念能够较容易理解为“隐藏细节”,但很多OO程序员做得非常不好,一方面是分配职责不当,一方面是不明确哪些内容需要封装;继承这个概念来自我们日常生活,因而是最容易理解的,但很多OO程序员最容易用错。因为看到了继承带来的便利性,因而在各种牵涉到重用与可扩展的场景下,大量使用继承,而忽略了继承其实仅仅是一种“差异式编程”;多态这个概念最为抽象,但只要理解了在不同时机体现为不同角色的思想,反而是OO程序员比较容易接受的。

        大约是5年前,我认为是这个现状;5年后,现状仍然如此。
      • _张逸_:我查了我很早以前在博客园的博客,因为我依稀有印象,我曾经写过一篇博客谈过这种暴露容器的问题。博客名字叫《精益求精,抑或得过且过》,http://www.cnblogs.com/wayfarer/archive/2011/02/18/1957530.html

        这是2011年的博客。其中写道:
        事实上,在我看到这样的Map时,就明白这种“超级强大”的容器,事实上往往会成为坏代码的泥沼。当我们将这样的对象作为参数,在方法之间传来传去的时候,会带来诸多问题:
        1)性能影响。这种可能比较庞大的容器对象,有可能会形成性能瓶颈;
        2)强类型。虽然这里使用了泛型,但泛型类型却使用了基本类型;
        3)封装性不够好。这样的容器对象暴露了太多的数据细节,且不利于为其定义职责行为。
        4)可读性差。看到这样的Map,你并不会在第一时间明白它到底存放了什么。
        5)可扩展性差:当这个Map作为方法的参数时,相当于这个参数没有被对象化。如果拥有这个参数的方法被公开,且广泛调用,一旦需要改变参数,牵连到的代码就会非常多。

        可惜在当时,我没有选择重构,而是选择了得过且过。当然,也有一个原因,那就是我要修改的那个产品本来就是一个要推翻的产品,我不可能花时间却解决这个问题。

        很遗憾,到了OO发展超过30多年的年头,许多OO的开发者在封装性这点仍然做得不好。
      • 5639d0517a48:袁创出品,篇篇精品

      本文标题:容器与封装

      本文链接:https://www.haomeiwen.com/subject/vueqjttx.html