为什么从代码中调用事件处理程序是一种不好的做法?

75

假设您有一个菜单项和按钮,它们执行相同的任务。为什么在一个控件的动作事件中放置任务代码,然后从另一个控件调用该事件是不好的做法呢?Delphi和VB6允许这样做,但Realbasic不允许,并建议将代码放入一个方法中,然后通过该方法调用菜单和按钮。


6
赞同此观点,我认为所有对Delphi编程感兴趣的人都应该知道这是一种不好的做法。在我开始使用动作(Action)之前(正如Rob Kennedy在他的第三点中提到的),我已经“烹制”了相当多的“意大利面条”式的应用程序,它们非常难以维护,这是很遗憾的,因为那些应用程序相当不错。但我渐渐地厌恶了自己的创作。在我看来,Rob的答案非常好而且详尽。 - Peter Perháč
9个回答

85

这取决于你程序的组织方式。在你所描述的情境中,菜单项的行为将根据按钮的行为来定义:

procedure TJbForm.MenuItem1Click(Sender: TObject);
begin
  // Three different ways to write this, with subtly different
  // ways to interpret it:

  Button1Click(Sender);
  // 1. "Call some other function. The name suggests it's the
  //    function that also handles button clicks."

  Button1.OnClick(Sender);
  // 2. "Call whatever method we call when the button gets clicked."
  //    (And hope the property isn't nil!)

  Button1.Click;
  // 3. "Pretend the button was clicked."
end;

任何这三种实现方式都可以工作,但为什么菜单项要如此依赖按钮呢?按钮有什么特别之处,应该定义菜单项吗?如果新的UI设计取消了按钮,菜单会发生什么变化?更好的方法是将事件处理程序的操作因素分离出来,使其与所附加的控件无关。有几种方法可以做到这一点:
  1. One is to get rid of the MenuItem1Click method altogether and assign the Button1Click method to the MenuItem1.OnClick event property. It's confusing to have methods named for buttons assigned to menu items' events, so you'll want to rename the event handler, but that's OK, because unlike VB, Delphi's method names do not define what events they handle. You can assign any method to any event handler as long as the signatures match. Both components' OnClick events are of type TNotifyEvent, so they can share a single implementation. Name methods for what they do, not what they belong to.

  2. Another way is to move the button's event-handler code into a separate method, and then call that method from both components' event handlers:

    procedure HandleClick;
    begin
      // Do something.
    end;
    
    procedure TJbForm.Button1Click(Sender: TObject);
    begin
      HandleClick;
    end;
    
    procedure TJbForm.MenuItem1Click(Sender: TObject);
    begin
      HandleClick;
    end;
    

    This way, the code that really does stuff isn't tied directly to either component, and that gives you the freedom to change those controls more easily, such as by renaming them, or replacing them with different controls. Separating the code from the component leads us to the third way:

  3. The TAction component, introduced in Delphi 4, is designed especially for the situation you've described, where there are multiple UI paths to the same command. (Other languages and development environments provide similar concepts; it's not unique to Delphi.) Put your event-handling code in the TAction's OnExecute event handler, and then assign that action to the Action property of both the button and the menu item.

    procedure TJbForm.Action1Click(Sender: TObject);
    begin
      // Do something
      // (Depending on how closely this event's behavior is tied to
      // manipulating the rest of the UI controls, it might make
      // sense to keep the HandleClick function I mentioned above.)
    end;
    

    Want to add another UI element that acts like the button? No problem. Add it, set its Action property, and you're finished. No need to write more code to make the new control look and act like the old one. You've already written that code once.

    TAction goes beyond just event handlers. It lets you ensure that your UI controls have uniform property settings, including captions, hints, visibility, enabledness, and icons. When a command isn't valid at the time, set the action's Enabled property accordingly, and any linked controls will automatically get disabled. No need to worry about a command being disabled through the tool bar, but still enabled through the menu, for example. You can even use the action's OnUpdate event so that the action can update itself based on current conditions, instead of you needing to know whenever something happens that might require you to set the Enabled property right away.


1
很棒的答案,谢谢。我特别印象深刻的是TAction方法,这是我之前不知道的最好的方法。实际上,Delphi似乎已经很好地覆盖了这个领域,允许所有方法。顺便说一下,您提到TAction允许自动禁用相关控件。最近我喜欢的风格变化之一是不在操作不可用时禁用控件,而是允许用户单击控件,然后给他们一个解释为什么操作没有发生的消息。 - jjb
如果使用TAction方法,其他方式的一些优势可能会变得无关紧要。 - jjb
3
在我看来,即使某些操作目前不可用,但不禁用控件会导致用户界面非常混乱。但是由于禁用的控件确实会使UI难以发现,因此当鼠标悬停在禁用的控件上时,应该有一些指示原因的工具提示或状态栏帮助消息。我更喜欢这种方法,而不是没有显示其状态的UI。 - mghie
@mghie 是的,这听起来像是两全其美的最佳选择。 - jjb
1
<sigh>。你对TAction做什么并不重要。重要的是它让你确保一切都以相同的方式工作。 - Rob Kennedy
1
@jjb:即使您保留了控件的启用状态,您仍然能够始终将正确的响应通道到TAction中,而不是在每个UI元素中提供错误响应。自然的下一步是将TAction中的逻辑分离为较低级别、与UI无关的对象,TAction调用这些对象,使UI依赖于较低级别的对象,而不是相反。 - Galdur

14

你应该将内部逻辑分离到其他函数中并调用此函数...

  1. 从两个事件处理程序中
  2. 如果需要,可以单独从代码中分离

这是一种更优雅的解决方案,也更容易维护。


在我看来,这不是问题的答案。我问为什么你不能做A而非B,而这个答案只是说因为B更好! - jjb
2
B更优雅的解决方案并且更易于维护,这是来自我个人经验的结论。实际上,个人经验不能用硬数据证明,这就是体验和科学证明之间的区别。而谈到优雅...你只能感受到它,无法定义它...最后可以参考Steve McConnell的《代码大全》,他对此类问题有相当好的覆盖面。 - smok1
好的,我会搜索我的代码档案并放置一些代码作为示例。 - smok1
@jjb - 我已经添加了一个详细的示例作为另一个答案。 - smok1
你应该举例说明为什么你的方法更好(例如更易于维护)(Rob的回答就做到了这一点,所以他获胜了)。 - Ian Boyd
显示剩余2条评论

10
这是承诺的延伸回答。 在2000年,我们开始使用Delphi编写应用程序。这是一个包含逻辑的EXE和几个DLL的集合。由于它是电影行业,因此有顾客DLL、预订DLL、售票处DLL和结算DLL。当用户想要进行结算时,他打开适当的表单,在列表中选择客户,然后OnSelectItem逻辑将客户剧院加载到下一个组合框中,然后在选择剧院后,下一个OnSelectItem事件会将未结算的电影信息填充到第三个组合框中。流程的最后一部分是按下“Do Invoice”按钮。所有操作都是作为事件过程完成的。
然后有人决定我们应该有广泛的键盘支持。我们添加了从另一个事件处理程序调用事件处理程序的功能。事件处理程序的工作流开始变得复杂起来。
两年后,有人决定实现另一个功能-使在另一个模块(客户模块)中处理客户数据的用户显示一个名为“Invoice this customer”的按钮。此按钮应触发发票表单并以在手动选择所有数据的用户状态呈现它。由于客户数据是一个DLL文件,而结算是另一个DLL文件,因此是EXE传递消息。因此,显而易见的想法是,客户数据开发人员将具有单一ID作为参数的单个例程,并且所有此逻辑都将位于结算模块中。
试想发生了什么。由于所有逻辑都在事件处理程序内部,我们花费了大量时间,实际上不是实现逻辑,而是尝试模拟用户活动-例如选择项目,在全局变量中挂起Application.MessageBox等等。想象一下-如果我们甚至有简单的逻辑过程调用事件处理程序内部,我们将能够引入DoShowMessageBoxInsideProc布尔变量到过程签名中。这样的过程可以使用true参数从事件处理程序中调用,使用FALSE参数从外部位置调用。
因此,这就教会了我不要直接将逻辑放在GUI事件处理程序中,除非是小型项目的可能性例外。

2
谢谢你发布这个。我觉得它清楚地阐明了你要表达的观点。我喜欢使用布尔参数的想法,可以在事件实际发生时与通过代码完成时具有不同的行为。 - jjb
1
如果你将发送者(sender)设置为nil,那么你可能会得到不同的行为 ;) - inzKulozik
@jjb: 我认为这是一个更广泛的话题,涉及到在两个不同的程序中具有相似逻辑。当你遇到这种情况时,最好提供第三个过程来包含实际逻辑,并将那两个相似的过程转换为包装器。行为上的差异可以通过控制参数来实现。许多组件都有两个或多个重载方法,例如Open。这些open方法通常是一些私有InternalOpen过程的包装器,其中包含用于进行一些小调整的布尔参数。 - smok1
@inzKulozik:是的,使用UI逻辑来进行转向控制,实际上使用niled Sender作为布尔控制变量...我认为这比声明var a、b、c、d、e、f、g: integer更好,以防万一;) - smok1

9

关注点分离。 对于一个类的私有事件,应该封装在该类中,而不是从外部类调用。这样,如果对象之间具有强接口并且最小化多个入口点的发生,则可以使您的项目更轻松地进行更改。


1
我同意封装和分离的概念,但是在VB6控件上,单击/双击事件从来都不是私有的。如果它们没有被设置为私有,那么这是因为有人认为它们对系统的影响很小。 - jpinto3912
在Delphi/Lazarus中,它们都没有被发布(RTTI'd)。 - Marco van de Voort
@jpinto3912 - 实际上,VB6事件处理程序默认为私有。 - MarkJ
这不是一个事件,而是一个事件接收器。甚至不是接收器本身,而是编译器生成的接收器所调用的逻辑。根据这个线程中看到的大多数逻辑,VB6事件处理程序除了调用另一个(冗余的)过程之外,永远不会有任何代码!坦率地说,我不相信这一点,而且发生的情况应该足够少。如果有人很担心,实现逻辑的处理程序可以与调用它的处理程序分组,并放置详细的注释以指导未来的维护者。 - Bob
@jpinto3912:事件是公共的,但处理程序是私有的。 事件实际上是(隐藏的,但公共的)事件接收器接口上的方法。 (私有的)事件处理程序方法是(公共的)事件接收器接口上方法的实现。类似于使用“Implements”关键字实现接口会默认创建“Private”方法的方式,但事件和事件处理程序被特殊处理(即您不必为类公开的所有事件实现处理程序,编译器在编译时插入空事件处理程序)。 - Mike Spross
当然,你可以选择将事件处理程序标记为“Public”,但这会违反最佳实践。IDE 将方法标记为“Private”,以保持与 COM 编程模型的一致性:即由于它们正在实现一个接口,因此它们应该在类本身内部是私有的,因为它们不是类自己接口的一部分,而是实现了类实现的接口。 - Mike Spross

9
假设你某个时刻决定菜单项不再有意义,并且想要删除它。如果只有一个其他控件指向菜单项的事件处理程序,那可能不是一个大问题,你可以将代码复制到按钮的事件处理程序中。但如果有多种不同的方式可以调用代码,你就必须做很多更改。
个人而言,我喜欢Qt的处理方式。有一个QAction类,它有自己的事件处理程序,可以被挂钩,然后将QAction与需要执行该任务的任何UI元素相关联。

1
好的,这对我来说是合乎逻辑的。当您删除按钮时,没有任何提示告诉您其他控件正在引用它。还有其他原因吗? - jjb
3
Delphi也可以做同样的事情。给菜单项和按钮分配一个动作——对于镜像菜单功能的工具栏按钮,我经常这样做。 - TheArtTrooper
1
另一个原因可能是当选择菜单项时,您想要进行某种用户界面更新,而在选择按钮时不适用。在大多数情况下,这样做并没有本质上的问题,但这只是一种有问题的设计决策,会限制灵活性。 - Gerald

8
另一个重要原因是为了可测试性。当事件处理代码嵌入到UI中时,唯一的测试方法就是手动测试或者与UI紧密耦合的自动化测试。(例如:打开菜单A,点击按钮B)。任何UI的改变都可能会导致数十个测试用例失效。
如果将代码重构为专门处理其任务的模块,则测试变得更加容易。

4

显然,这样更整洁。但是易用性和生产力当然也非常重要。

在Delphi中,我通常不在严肃的应用程序中使用它,但在小型项目中调用事件处理程序。如果小项目变成了更大的项目,我会进行清理,并通常同时增加逻辑-UI分离。

我知道在Lazarus/Delphi中这并不重要。其他语言可能会附加更多特殊行为到事件处理程序。


听起来像是一个务实的政策。 - jjb

2

为什么这样做是不好的实践?因为如果代码嵌入到UI控件中,那么重用代码就会变得更加困难。

为什么在REALbasic中不能这样做呢?我怀疑没有任何技术原因,这很可能只是他们做出的设计决策。这确实可以执行更好的编码实践。


这是不允许在事件中除了调用之外的任何操作的理由吗?如果首先必须在事件中查找代码所在的方法名称,那么总是需要额外的查找。此外,不得不为无数个方法思考有意义的名称也变得非常乏味。 - jjb
1
不,这是一个不尝试重用事件中的代码的论点。如果代码仅适用于事件,则我会将其放在事件中。但是,如果我需要从任何其他地方调用它,则将其重构为自己的方法。 - Paul Lefebvre
是的,那个方法看起来非常有道理。谢谢。 - jjb

1
假设在某个时候,您决定菜单应该稍微有所不同。也许这个新变化只会在某些特定情况下发生。您忘记了按钮,但现在您也改变了它的行为。
另一方面,如果您调用一个函数,您更不可能改变它的功能,因为您(或下一个人)知道这将产生不良后果。

1
我不同意你的逻辑。如果你有一个菜单项和一个按钮来完成相同的事情,它们应该做相同的事情,而不是有不同的功能。换句话说,如果你有一个菜单项允许你编辑数据库中的当前行,还有一个按钮允许你编辑数据库中的当前行,两者都应该做相同的事情;否则,它们都不应该被称为“编辑”。 - Ken White
@Ken 可能有很好的理由让菜单和按钮执行不同的操作。例如,在 VB6 中,当用户单击菜单项时,它不会在具有焦点的控件上触发失去焦点事件。当用户单击按钮时,它会触发失去焦点事件。如果您依赖失去焦点事件(例如进行验证),则可能需要在菜单单击事件中编写特殊代码来触发失去焦点并在发现验证错误时中止。您不需要从按钮单击获取此特殊代码。 - MarkJ

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接