设计方法:分离接口与抽象类型 (C++)

晨曦之光 发布于 2012/05/23 11:02
阅读 185
收藏 1

原文:设计方法:分离接口与抽象类型
作者:Breaker <breaker.zy_AT_gmail>


以一个极简的 stack 堆栈类为线索示例,考量它作为容器类设计的一般思路,归纳 如何使用 分离接口 与 抽象类型 的思想提高代码复用性

简单 stack 类示例:

class MiException
{
public:
    virtual ~MiException()
    {}
};
class MiUnderflow : public MiException
{};
class MiOverflow : public MiException
{};
class MiBadSize : public MiException
{};

class MiStackOld
{
public:
    MiStackOld(int cap = 1024) : m_top(0)
    {
        if (cap < 0)
            throw MiBadSize();
        m_capacity = cap;
        m_val = new int[cap];
    }
    ~MiStackOld()
    {
        delete[] m_val;
    }

    void push(int val)
    {
        if (m_top == m_capacity)
            throw MiOverflow();
        m_val[m_top] = val;
        m_top++;
    }

    int pop()
    {
        if (m_top == 0)
            throw MiUnderflow();
        m_top--;
        return m_val[m_top];
    }

    int size() const
    {
        return m_top;
    }

private:
    int*    m_val;      // 保存 stack 数据元素的数组
    int     m_capacity;
    int     m_top;      // 在 m_val 中将要放置但还没有放置的位置
};
这个 stack 类没有实用和复用价值,原因和改进方法如下:

容器的复用性

1. 容器只能存储一种类型的元素

改进方法:

(1). 容器中存储指针,如约定的根类 Object*,或无类型指针 void*

特点:

1). 元素不同质问题,由用户代码确定元素的具体类型,如出栈时需要向下强制转换指针

2). 元素存储的负责问题,是由用户代码负责释放每个元素存储,还是由容器负责释放元素存储,这需要一个约定

(2). 使用类模板,存储的元素为模板参数类型。标准库容器使用这种方法

特点:

1). 同质容器,如果要存储多态对象元素,可以存储其指针 Shape*,这时需要用户代码确定具体类型,一般用 dynamic_cast 对容器返回的指针(要求必须是多态对象)做向下强制转换

2). 将元素存储到容器的过程执行 拷贝语义(拷贝构造函数 或 赋值 operator=()),所以如果对象的拷贝语义不是按成员拷贝(默认),那么需要覆盖拷贝构造函数 和 赋值操作 operator=(),这常见于含动态存储的对象。存储大对象、存储元素较多时,存在拷贝效率问题,此时可改用存储指针

3). 由于是将元素 拷贝 到容器,所以:

a). 如果元素类型是对象类型(类、POD),那么存储到容器里的对象 和 用户代码里的对象,在存储的负责上没有过多的纠缠,容器中对象的存储释放由容器负责

b). 如果元素类型是指针类型,那么需要用户代码负责容器中的对象释放,即指针指向的存储。这是编程易错点,如 用户程序中对象已释放,但容器仍存储其指针,即存储对象指针时,用户程序的存储管理的编程难度会增大,这和 2) 的直接存储对象的效率构成一个两难问题,一种普适的解决方法是:对待大对象时,容器中存储 带引用计数的 指针封装类/智能指针,而非对象原始指针

2. 容器的内部实现方法和它的通用概念无关,应该将其通用概念分离出来

stack 概念和数组、动态存储等实现细节无关,只和 push、pop、下溢、上溢(有容量上限时)、当前元素数量 等操作有关

需要将这种 stack 概念分离出来,称为 分离接口(分离界面)。分离接口的好处很多,它是 隔离变化 设计思想的一种常见方法,目的是:形成约定的接口规格、易于扩展新的实现方法

改进方法:

(1). 继承基类操作 的方法:基类用虚/纯虚函数定义接口方法,在子类实现

使用 继承的分离接口方法 和 元素类型模板参数 stack 示例:

template <typename Type>
class IMiStack
{
public:
    virtual void push(const Type& val) = 0;
    virtual Type pop() = 0;
    virtual int size() = 0;
};

template <typename Type>
class MiStackVec : public IMiStack<Type>
{
public:
    void push(const Type& val)
    {
        m_vec.push_back(val);
    }
    Type pop()
    {
        Type val = m_vec.back();
        m_vec.pop_back();
        return val;
    }
    int size()
    {
        return (int) m_vec.size();
    }
private:
    std::vector<Type>   m_vec;
};
特点:

1). 在定义对象时,需要指定确切类型 MiStackVec stack;在中间函数中,可用父类(接口类)的引用、指针进行接口方法操作,如 void UseStack(IMiStack& s)

2). 继承结构不宜复杂,陷入继承泥潭,对于容器类库的维护 和 用户对接口的理解都很困难

(2). 适配器 (adapter):使用 组合对象 和 类模板 的方法,标准库容器 std::stack 使用这种方法

使用 适配器类模板 和 元素类型模板参数 stack 示例:

template <typename Type, typename Container = std::deque<Type>>
class MiStack
{
public:
    MiStack(const Container& c = Container()) : m_container(c)
    {}

    void push(const Type& val)
    {
        m_container.push_back(val);
    }

    Type pop()
    {
        Type val = m_container.back();
        m_container.pop_back();
        return val;
    }

    int size() const
    {
        return m_container.size();
    }

private:
    Container   m_container;
};
特点:

1). MiStack 称为其组合对象类 Container 的 adapter,出发点是以 非侵入 的方式调整 (non-intrusively adapting) 组合对象提供的操作

2). adapter 的实现约束于作为基础的组合对象提供的操作。如 MiStack 的组合对象必须提供 push_back()、pop_back() 等方法,否则 MiStack 模板不能实化

3). 鉴于以上两点,一般 adapter 方法更倾向于调整已有接口,而不是分离接口

模板分离接口的思想:考察下面简单函数模板:

template<typename Type>
inline Type& max(Type& l, Type& r)
{
    return (l > r? l : r);
}
max() 是接口,它将 偏序操作 operator<() 调整为求最大值。标准库通用算法大量采用这种方法

(3). Windows COM: 使用 组合对象 和 嵌套类,参考《VC 6 技术内幕》第 5 版,第 24 章 组件对象模型

1). COM 使用了面向接口的设计思想,在思路上接近 C++ 的继承机制

2). 但实现时却没用 C++ 原生的继承机制,而用 组合对象 + 嵌套类 的方法实现。原因是:如果有两个接口,用 C++ 继承时就需要两个基类(多继承),但如果两个接口有相同的方法名,用嵌套类要比用 C++ 多继承更容易实现

容器的健壮性

1. 多线程支持

容器类是潜在的被并发访问的共享资源类,需要考虑其并发/线程安全性。标准库的实现者会提供相关的线程安全说明文档,如 VC 2005 的 C++ 标准库参考 MSDN:Thread Safety in the Standard C++ Library

它对容器类和 complex 的线程安全说明如下:

1). For reads to the same object, the object is thread safe for reading in the following scenarios:
From one thread at a time when no writers on other threads.
From many threads at a time when no writers on other threads.

对同一个对象的并发读是线程安全的,但前提是不能同时有写

2). For writes to the same object, the object is thread safe for writing from one thread when no readers on other threads

只支持对一个对象的单线程写,且前提是不能同时有读

3). For reads to different objects of the same class, the object is thread safe for reading in the following scenarios:
From one thread at a time.
From one thread at a time when no writers on other threads.
From many threads at a time.
From many threads at a time when no writers on other threads.

4). For writes to different objects of the same class, the object is thread safe for writing in the following scenarios:
From one thread when no readers on other threads.
From many threads.

3) 和 4) 是说对同一类的不同对象的读、写都是线程安全的

说明 VC 2005 C++ 标准库不支持同一容器对象的以下并发要求:

(1). 读-写并发
(2). 写-写并发

2. 拷贝语义

这里指整个 stack 对象的拷贝,而非单个存储元素

拷贝语义发生在 1) 初始化 2) 赋值 两种情况下

对于 stack 等容器类 和 复杂的类(含动态存储、组合对象、链接成员对象),拷贝都是 check list 中的关注点,不能依靠默认的按成员拷贝机制,虽然有时结果正确,但可能是碰巧,规范的做法是 覆盖 拷贝构造函数 和 赋值操作 operator=()

如果赋值需要两个以上右值的参数,便不能用 operator=(),参考标准库中很多容器的 assign() 方法

3. 操作的简单语义

以标准库的 stack 的 top() 和 pop() 操作为例,标准库的设计者将这两个操作分开,而不是做成单一一个既删除栈顶又返回栈顶值的函数 pop_top(),原因如下:

(1). 有时需要访问栈顶元素,但不需要删除它

(2). 如果定义了 pop_top(),可能需要多一些的拷贝传递,而标准的 top() 只是返回容器中的对象引用,没有拷贝开销

(3). top() 只有读语义,pop() 只有写语义,针对不同场景进行分别加锁同步,比对单一的 pop_top() 加锁更灵活,可以提升效率

这种设计思想是:使接口操作保持最小不重叠,且每一操作都有完整语义

如果确实需要 pop_top() 操作,可以用 adapter 或继承的方法实现新的类,如果觉得为了一个 pop_top() 方法定义一个新类没什么必要,可以定义一个辅助函数,如下:

template<typename Type>
Type& pop_top(stack<Type>& s, __out Type& o)
{
    o = s.top();    // 1 次拷贝,要求 Type 有适当的赋值语义 operator=()
    s.pop();
    return o;       // 这里没有拷贝,只是传回 o 的引用,目的是方便连写,如 pop_top(s, obj).do_something()
}
这里用传出参数 + 返回引用的方法,是为了降低拷贝开销,而下面这种单纯用返回值(拷贝)的方法是不可取的:

// 低效率的 pop_top() 实现
template<typename Type>
Type pop_top(stack<Type>& s)    // 不能返回 Type&,因为 t 是局部变量
{
    Type t = s.top();           // t 不能是引用,因为下一步 pop() 就会销毁栈顶,第 1 次拷贝
    s.pop();
    return t;                   // 第 2 次拷贝
}
另外,标准库的 pop() 操作是没有下溢保护的,top() 也没有容器为空时的保护,VC 的实现只是用 assert 告警错误。其它一些标准容器,如 vector 的 operator[] 也没有下标保护(但 at() 有下标保护)

这种设计思想是:约束性程序设计(对应 保护性程序设计),保护由用户程序负责

(1). 优点是代码的效率高。标准库比较在乎效率,原则是,可以在一个快速机制上构建一套安全功能,如可由用户程序做多线程同步、界限检查,但很难在慢速机制上构建一套快速功能,如 stack 中如果有太多锁、界限检查,那么这种开销是固有的,无论是否遇到需要保护的应用场景,如单线程应用,根本不需要同步

(2). 缺点是要准确使用标准库,需要充分地阅读文档参考,因为约定的多,被保护的少,直觉和臆断就越靠不准

一般的设计原则:在模块间调用(DLL 导出函数)使用保护性设计,内部机制(效率敏感处)使用约束性设计


原文链接:http://blog.csdn.net/breakerzy/article/details/6889175
加载中
返回顶部
顶部