『重构--改善既有代码的设计』读书笔记---Duplicate Observed Data

当MVC出现的时候,极大的推动了Model与View分离的潮流。然而对于一些已存在的老系统或者没有维护好的系统,你都会看到当前存在大把的巨大类----将Model,View,Controller都写在了一个widget中。一个分层良好的系统,应该将处理用户界面和处理业务逻辑的代码分开。原因如下

  1. 如果你此时需要用不同的用户界面来展示数据,比如微软Excel中的饼状图和折线图,他其实内部展示的数据是一样的,但如果你把这两层用户界面逻辑都放在一个widget中去的话,你就会让这个wiget变得复杂无比,因为他同时承担了两个责任,一个是“饼状图”一个是“折线图”。
  2. 当你让Model与GUi分离之后,你可以让他们两个之间的维护和演化变得更加容易,你甚至可以让不同的开发者进行分别的开发。

分离之中最困难的就是数据的分离,因为你可以很轻松的把行为划分到不同部位,但数据却没这么容易。因为你需要考虑它的同步问题,举个例子,如果你此时的GUI空间需要显示你Model中的name放到一个单独的label中去,那么你可能需要内嵌于GUI的同时,也需要在Model中也保存一份。自从MVC出现之后,用户界面框架都使用多层系统来提供某种机制,使得你不但可以提供这类数据,并保持它们同步

如果你遇到的代码不像上面所讲的单层方式,而是两层方式开发--业务被内嵌于用户界面之中,你就有必要将行为分离出来。行为分离主要的工作就是函数的分解和搬移,但数据就不同了,你不能仅仅只是移动数据,你必须将他复制到新的对象之中,并提供相应的同步机制。

做法:

  • 修改View类,使其成为Model类的观察者(Observer),如果没有Model类就建立一个,如果没有从View到Model的关联,就将Model作为View的一个字段存入。
  • 针对GUI中的Model数据,使用Self Encapsulate Field
  • 编译,测试。
  • 在事件处理函数中调用设置函数,直接更新GUI。在事件处理函数中放一个设值函数,利用它将GUI组件更新为Model的当前值,当然这其实没有必要,因为你只是拿它的值设置它自己。但是这样使用设值函数,便是允许其中的任何动作得以于日后被执行起来,这是这一个步骤的意义所在。进行这个改变时,对于组件View,不要使用取值函数,应该直接取用,因为我们稍后将修改取值函数,使其从Model对象中取值而非在GUI中,设值函数也将做类似修改。
  • 编译,测试。
  • 在Model类中定义数据以及相关访问函数,确保Model类的设值函数能够触发Observer模式的通报机制(update)。对于被观察的数据,在Model使用与View中相同的数据类型(通常是字符串),后续重构你可以自由改变这个类型。
  • 修改View中的访问函数,使它的操作对象改为Model(而非GUI)。
  • 修改Observer的update(),使其从相应的Model中将所需要数据复制给GUI。(PS:Observer模式中对于数据更新存在“推“和”拉“两种方式,这里介绍的是的“拉”数据)
  • 编译,测试。

例子:

我们假设有三个文本框,一个是Start,一个是End,一个是Length,其中Length是Start和End之间的差值,你随即修改任何值,相应的另外两个都会刷新。比如你修改了Length,相应的End就会更新,你修改了Start或者End,Length就会得到更新。一开始我们的做法就是将业务逻辑都放在了View中,已知Qt中存在这样的焦点机制

void QApplication::focusChanged ( QWidget * old, QWidget * now ) [signal]

他会根据焦点的丢失,QApplication会发出相应的信号出来,这里我们需要关注的是old,因为这个指针代表了失去焦点的widget所代表的指针,我们就可以通过他来判断到底是哪个widget失去了焦点。于是我们在自己的IntervalWindow中建立与QApplication的信号槽

connect(QCoreApplication::instance, SIGNAL(focusChanged(QWidget *, QWidget *), this, SLOT(onFocusChanged(QWidget *, QWidget *))));

这样我们就可以在自己的槽函数onFocusChanged中针对上述3个widget:m_startField,m_endField,m_lengthField做对应的焦点处理

void onFocusChanged(QWidget *old, QWidget *now)
{
    QWidget *w = old;

    if (w == m_startField)
    {
        startField_focusLost();
    }
    else if (w == m_endField)
    {
        endField_focusLost();
    }
    else if (w == m_lengthField)
    {
        lengthField_focusLost();
    }
}

可以看到,当任意一个指针失去焦点都会进入到相应的函数当中去,处理函数大致如下

void startField_focusLost()
{
    bool ok;
    int num = m_startField->getText().toInt(&ok);

    if (ok)
    {
    }
    else
    {
        m_startField->setText("0");
    }

    calculateLength();
}

void endField_focusLost()
{
    bool ok;
    int num = m_endField->getText().toInt(&ok);

    if (ok)
    {
    }
    else
    {
        m_endField->setText("0");
    }

    calculateLength();
}

void lengthField_focusLost()
{
    bool ok;
    int num = m_lengthField->getText().toInt(&ok);

    if (ok)
    {
    }
    else
    {
        m_lengthField->setText("0");
    }

    calculateEnd();
}

其中有一个需要注意的就是当用户输入的是非法字符不能成功转成数字的时候,这里将自动变成0.下面是两个具体的计算函数

void calculateLength()
{
    int start = m_startField->getText().toInt();
    int end = m_endField->getText().toInt();

    int length = end - start;

    m_lengthField->setText(QString::number(length));
}

void calculateEnd()
{
    int start = m_startField->getText().toInt();
    int length = m_lengthField->getText().toInt();

    int end = start + length;

    m_endField->setText(QString::number(end));
}

我们的任务就是将与GUI无关的相关计算抽离出来,基本上这就意味着我们需要把calcuateLength()和calcuateEnd()放到Model中去,为了这一个目的我们需要在不能引用View类的前提下获取三个文本框的值。唯一办法就是将这些数据复制到Model类中,并且保持与GUI之间的同步,这就是Duplicate Observed Data的任务。

到目前为止我们还没有一个独立的Model类,我们建立一个

class Interval : public Observable
{
};

其中Observable是最简单的观察者模式接口,里面实现的就是类似notify来便利订阅自己的各个客户进行相应update。我们需要建立一个View到Model的关联

Interval *m_subject;

然后我们需要合理的初始化m_subject,并把View当作这个Model的观察者,这很简单,只需要把下面代码放到View的构造函数中就可以了

m_subject = new Interval();
m_subject->addObserver(this);
update(m_subject);

我们习惯把这段代码放到构造函数的最后,其中对update的额外调用可以当我们把数据放到Model类后,GUI将根据Model类进行相应初始化。当然了,我们的View类此时应该继承Observer接口

class IntervalWindow : public Observer
{
};

并且覆写update函数,此时先写上一个空实现

void update(Observable *observed)
{
}

现在我们进行编译测试,虽然我们到目前为止还没有进行任何实质性修改,但依然需要小心。

接下来我们把注意力放到文本框上,我们从End文本框开始,第一件事情就是运用Self Encapsulate Field,文本框的更新是通过getText()和setText()来实现的,因此我们所建立的访问函数需要调用这两个函数

QString getEnd()
{
    return m_endField->getText();
}

void setEnd(const QString &arg)
{
    m_endField->setText(arg);
}

然后我们找到m_endField的所有引用点,将他们替换为相应的访问函数(这其实已经在做解耦操作,让计算逐渐脱离相关GUI的依赖

void calculateLength()
{
    int start = m_startField->getText().toInt();
    int end = getEnd();

    int length = end - start;

    m_lengthField->setText(QString::number(length));
}

void calculateEnd()
{
    int start = m_startField->getText().toInt();
    int length = m_lengthField->getText().toInt();

    int end = start + length;

    setEnd(QString::number(end));
}

void endField_focusLost()
{
    bool ok;
    int num = getEnd();

    if (ok)
    {
    }
    else
    {
        setEnd(QString::number(0));
    }

    calculateLength();
}

先做自我包装再做引用点更换,这是Self Encapsulate Field的标准过程,然而当我们处理GUI的时候,情况更为复杂:用户可以通过GUI修改文本框内容,不必通过setEnd(),因此我们需要在GUI事件处理函数中调用setEnd(),这个动作把End文本框设置为当前值,这没有带来什么影响,但是通过这样的方式,可以确保用户的输入确实是通过设值函数进行的,你这样就可以预防并且控制所有可能的情况。

void endField_focusLost()
{
    setEnd(m_endField->getText());

    bool ok;
    int num = getEnd();

    if (ok)
    {
    }
    else
    {
        setEnd(QString::number(0));
    }

    calculateLength();
}

细心的朋友可能会看到这里为什么没有使用getEnd()而是直接去操作文本框来获取,之所以这样做是因为我们随后的重构将使getEnd()从Model对象取值,那时如果这里使用的是getEnd(),每当用户修改文本框内容,这里就会将文本框变为原来值,所以在这里需要特别注意我们必须用直接通过文本框来获取最新值,现在我们可以编译并且测试封装后的行为了。现在我们可以给Model增加m_end字段。

    private:
        QString m_end;

在这里我们给他的初值和GUI给他的初值是一样的,然后我们再加入取值/设值函数结果如下

class Interval : public Observable
{
    public:
        Interval() :
            m_end("0")
    {
    }
        QString getEnd()
        {
            return m_end;
        }

        void setEnd(const QString &arg)
        {
            m_end = arg;
            setChanged();
            notifyObservsers();
        }
    private:
        QString m_end;
};

由于使用了Observer模式,我们必须在设值函数中发出通知,在这里我们暂且把m_end的类型设值为字符串,其实作为Model本身含义来将,采用int似乎更合理,但在这个时候我们应该尽可能将修改量减到最小,以小步伐来进行重构,倘若之后成功完成复制数据,我们可以很轻松的将m_end类型改为int。

现在我们可以编译并测试一次,我们希望通过所有这些预备工作,将下面这个较为棘手的重构步骤风险降到最低。

首先我们修改View类的访问函数,令他们改用Interval对象

class IntervalWindow : public Observer
{
    public:
        QString getEnd()
        {
            return m_subject->getEnd();
        }
        void setEnd(const QString &arg)
        {
            m_subject->setEnd(arg);
        }
};

同时我们修改update()函数,确保GUI对Interval对象发出的通告做出响应

void update(Observable *observed)
{
    Q_UNUSED(observed)

    m_endField->setText(m_subject->getEnd());
}

这是另外一个需要直接访问文本框的地方,如果我们这里不直接访问,采用setEnd()本身,那么我们的GUI控件将永远更新不到,并且程序本身会进入无限递归。总结来说,在这个重构步骤中真正需要接触GUI空间本身的就两个地方:

  1. 在事件处理函数中,为了获得GUI控件的最新值,必须通过控件本身去获取,不然如果你通过获取Model去获取,此时的Model依然是之前的那个值。
  2. 在最终得到更新的时候,要去修改GUI控件的值的时候,必须调用控件的set而不是你封装的set,不然除了控件得不到更新之外你还会进入无限循环。

总结来看,一个就是用户去接触GUI的那一刻,你需要去拿最新数据的时候,还有一个就是用最终set的时候,你需要真正set到GUI控件本身。这两个地方需要特别注意,必须直接操作,而不是调用间接委托函数。

现在我们可以编译并测试,数据都被恰如其分的复制了。另外两个文本框我们也如法炮制,完成之后,我们就可以运用Move Methd将calculateEnd()和calculateLength()搬移到Interval这个Model中去,这么一来我们就拥有了一个包容Model数据和行为并且与GUI分离的专属Model了。如果我们完成了上述重构,我们还可以做更夸张的事情就是我们可以完全摆脱这个GUI,去调用更新的GUI控件,让显示效果可以更好,这个绝对是我们不进行本次重构之前很难做到的。

当然了,有些时候可能你不想使用Observer模式,你可以使用事件监听器来同样完成Duplicate Observed Data。这种情况下你需要在Model类中建立一个监听器类和事件类,你需要对Model注册监听器,就像之前Observable对象注册Observer一样,每当Model发生变化(类似上述update()被调用),就向监听器发送一个事件,IntervalWindow可以使用一个内嵌类来实现监听器接口,并在适当的时候调用适当的update()。

时间: 2025-01-16 10:06:12

『重构--改善既有代码的设计』读书笔记---Duplicate Observed Data的相关文章

『重构--改善既有代码的设计』读书笔记----Extract Method

在编程中,比较忌讳的一件事情就是长函数.因为长函数代表了你这段代码不能很好的复用以及内部可能出现很多别的地方的重复代码,而且这段长函数内部的处理逻辑你也不能很好的看清楚.因此,今天重构第一个手法就是处理长函数--Extract Method,抽取成一个独立的小函数. 我个人来说也很喜欢短小函数,因为他们代表了高强度的复用与灵活性.对于短小函数来说最最关键的就是短小函数的命名,其实你就是给了这些短小函数自我解释的机会,所以你如果给这些短小函数起一个接近其语义的名字,那当你读起长函数来说,就像是阅读

『重构--改善既有代码的设计』读书笔记----Change Value to Reference

有时候你会认为某个对象应该是去全局唯一的,这就是引用(Reference)的概念.它代表当你在某个地点对他进行修改之后,那么所有共享他的对象都应该在再次访问他的时候得到相应的修改.而不会像值对象(Value)一样,不可修改.举个例子,你认识小明,我也认识小明,小明忽然把头发都踢了,这个时候你认识的小明和我认识的小明都是同一个人,都是光头,这个小明就是世界的唯一实例,然而,你有100块钱,我有50块钱,我把50块钱花到只剩20,你手里的100块钱并不会因为我的50块钱改变而改变,不会相应的修改,这

『重构--改善既有代码的设计』读书笔记----Replace Method with Method Object

有时候,当你遇到一个大型函数,里面的临时变量和参数多的让你觉得根本无法进行Extract Method.重构中也大力的推荐短小函数的好处,它所带来的解释性,复用性让你收益无穷.但如果你遇到上种情况,你可能会天真的以为我只要适当的进行Replace Temp with Query,就可以把这种现象给化解.但情况往往事与愿违,不能达到你所理想的高度.这个时候你需要用到重构中的杀手锏--Replace Method with Method Object,这个手法出自Kent Beck [Beck].

『重构--改善既有代码的设计』读书笔记----Move Method

明确函数所在类的位置是很重要的.这样可以避免你的类与别的类有太多耦合.也会让你的类的内聚性变得更加牢固,让你的整个系统变得更加整洁.简单来说,如果在你的程序中,某个类的函数在使用的过程中,更多的是在和别的类进行交互,调用后者或者被后者调用,那么你就要注意了,你要去判断这个类是否真正适合他原来所在的类. 简单来说,这套手法就是在该函数最常引用的新类中建立一个有着类似行为的新函数,让旧函数变成一个单纯的委托函数或者完全删掉. Move Method是重构理论的支柱.如果一个类的责任太多,或者一个类和

『重构--改善既有代码的设计』读书笔记----代码坏味道【3】

星期六了,适当出去放松了下,回来继续我们重构的话题.今天是坏味道[3]了,很多朋友跟我私信,叫我把坏味道出完,再出手法.其实这是有道理的,很多时候,"发现"远比"怎么做"重要的多.就拿设计模式来讲,GoF里面的设计模式相信有很多人都了解过.具体的设计模式应该怎么实现啊相信有很多人都背的滚瓜烂熟,但问题的难点往往在于你应该什么时候用这个设计模式.重构也一样,手法步骤都是死的,关键在于应该发现什么时候应该重构.所以,我还是决定继续出坏味道,把坏味道全部出完我们再去学手法

『重构--改善既有代码的设计』读书笔记----Extract Class

在面向对象中,对于类这个概念我们应该有一个清晰的责任认识,就是每个类应该只有一个变化点,每个类的变化应该只受到单一的因素,即每个类应该只有一个明确的责任.当然了,说时容易做时难,很多人可能都会和我一样,一开始建立类的时候信心满满,牢记SRP原则,但随着开发进度的不断进行,很有可能你会给你原本设计好的类增加新字段或者增加新函数,对于少量的增加你可能会因为麻烦,考虑不去单独做一个新类来分解.久而久之,你这个类会变得越来越臃肿,所掌管的责任也会越来越多.这样的类往往还有大量的数据和函数,往往太大而不易

『重构--改善既有代码的设计』读书笔记----Replace Data Value with Object

当你在一个类中使用字段的时候,发现这个字段必须要和其他数据或者行为一起使用才有意义.你就应该考虑把这个数据项改成对象.在开发初期,我们对于新类中的字段往往会采取简单的基本类型形式来保存,但随着我们开发进度的增加,这些简单的数据项就不再那么简单了.比如一开始你会使用一个字符串来表示一串电话号码,但是随后你会发现,这个电话号码已经变的不再纯粹,它可能还需要“格式化”,“抽取取号”等特殊行为.一开始你可能会不以为意,觉得这个数据项就这么一两个,不会对你造成影响.但重复代码(Duplicate Code

『重构--改善既有代码的设计』读书笔记----Substitute Algorithm

重构可以把复杂的东西分解成一个个简单的小块.但有时候,你必须壮士断腕删掉整个算法,用简单的算法来取代,如果你发现做一件事情可以有更清晰的方式,那你完全有理由用更清晰的方式来解决问题.如果你开始使用程序库,发现其中库提供的功能特性和你的代码重复,那么你也应该改变你原来的算法.或者当你想要修改原先的算法,让他去做一件和原先略有差异的事情,这时候你也可以把原先的算法替换成一个较易修改的算法,让后续修改来的简单点. 使用这个手法之前,确保自己已经充分了解原先函数,替换巨大而复杂的算法是很复杂的,你可以先

『重构--改善既有代码的设计』读书笔记----Replace Array with Object

如果你有一个数组,其中的元素各自代表不同东西,比如你有一个 QList<QString> strList; 其中strList[0]代表选手姓名,strList[1]代表选手家庭住址,很显然这个数组表示的含义已经太多,你需要用对象来替换数组,并且对于数组中的每个元素,以一个字段来表示. 数组是一种常见的用以组织数据的数据结构,不过,它们应该只用于“以某种顺序容纳一组相似对象”.对于上面的例子你可以看到一个数组容纳了不同对象,这会给使用数组的客户带来麻烦,因为他们很难记住数组的第一个元素是姓名,