阅读 25 :回调
防虫 | 容易明白 | 准备改变 |
---|---|---|
今天改正,在未知的未来改正。 | 与未来的程序员(包括未来的您)进行清晰的沟通。 | 旨在适应变化而无需重写。 |
在本文中,我们讨论了回调,实现者在其中调用客户端提供的函数。
我们在图形用户界面和Web服务器这两个上下文中讨论了这个想法,在这些上下文中,回调用于响应传入的输入事件。
但是,回调是更大创意,一流函数或将函数像数据一样对待的示例:将它们作为参数传递,将它们作为结果返回,并将它们存储在变量和数据结构中。在接下来的几个类中,我们将看到一流函数的更多用途。
在图形用户界面(GUI)中对输入的处理方式与在控制台用户界面和服务器中对输入的处理方式不同。在其他系统中,我们看到了一个输入循环,该循环读取用户键入的命令或客户端发送的消息,解析它们,并决定如何将它们定向到程序的不同模块。
如果以这种方式编写GUI电子邮件客户端,则可能看起来像这样(用伪代码):
while (true) { read mouse click if (clicked on Check Mail button) doRefreshInbox(); else if (clicked on Compose button) doStartNewEmailMessage(); else if (clicked on an email in the inbox) doOpenEmail(...); ... }
但是在GUI中,我们不直接编写这种方法,因为它不是模块化的。GUI是由各种库组件(按钮,滚动条,文本框,菜单)组合在一起的,它们必须是独立的并可以处理它们自己的输入。例如,这是创建按钮的方法:
JButton playButton = new JButton("Play");
在屏幕上看起来像这样:
运行
此按钮处理来自鼠标和键盘的自己的输入。图形用户界面库的深处是一个输入循环,该输入循环从鼠标和键盘读取,并将这些输入事件传递到GUI的适当组件。
为了使您的程序在单击按钮时可以执行某些操作,请在其上附加一个侦听器:
playButton.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent event) { playSound(); } });
此代码创建实现该接口的匿名类的实例,该实例ActionListener
只有一个方法actionPerformed
。当使用将该ActionListener
实例提供给按钮时addActionListener()
,该按钮承诺actionPerformed
每次用户按下按钮时都会调用其方法。
GUI事件处理是侦听器模式(也称为“发布-订阅” )的实例。在侦听器模式中:
在此示例中:
JButton
是事件源;ActionListener
实例actionPerformed
事件通常包含附加信息,这些附加信息可以捆绑到事件对象(如ActionEvent
here)中,或作为参数传递给侦听器函数。
发生事件时,事件源通过调用其所有侦听器方法将其分发给所有已订阅的侦听器。
因此,通过图形用户界面的控制流程如下所示:
最后一部分-侦听器尽快返回事件循环-非常重要,因为它保留了用户界面的响应能力。
侦听器模式不仅用于按钮按下。每个GUI对象通常都是由于低级输入事件的某种组合而生成事件。例如:
JButton
按下时发送动作事件(无论是通过鼠标还是键盘)JList
当所选元素更改时发送选择事件(无论是鼠标还是键盘)JTextField
当其中的文本由于任何原因更改时发送更改事件监听器
actionPerformed
我们在上一节中看到的listener函数是一个通用设计模式的示例,一个callback。回调是客户端提供给模块以供模块调用的功能。这与常规控制流相反,在常规控制流中,客户端执行所有调用:调用模块提供的功能。通过回调,客户端为实现者提供了一段代码来调用。
这是考虑这个想法的一个类比。正常功能呼叫就像提起电话并致电服务一样,例如致电您的银行以查找您的帐户余额。您提供了银行运营商查询您的帐户所需的信息,他们通过电话向您读回了帐户余额,然后您挂断了电话。您是客户,银行是您要调用的模块。
有时银行给出答案的速度很慢。您被搁置,等到他们为您找出答案。这就像一个函数调用,直到它准备好返回为止,它一直处于阻塞状态,这在我们讨论套接字和消息传递时已经看到。
但是有时任务可能会花费很长时间,银行也不想让您搁置。然后,银行会要求您提供回叫电话号码,然后他们会答应给您回电。这类似于提供回调函数。
侦听器模式中使用的回调类型不是对一次性请求(例如帐户余额)的答案。银行更希望提供常规服务,并根据需要使用您的回叫号码。听众模式的一个更好的类比是帐户欺诈保护,帐户中发生可疑交易时,银行会通过电话给您打电话。
让我们看一下使用回调进行输入处理的系统的另一个示例:Web服务器。Web服务器通常将其服务的网站划分为多个部分,称为“路由*”*。例如,的服务器web.mit.edu
可能具有以下路线:
/education
(可通过URL访问http://web.mit.edu/education
)/research
/community
等等。
Web服务器的控制流是一个输入循环,等待来自Web浏览器的传入连接。当新的连接到达时,服务器读取并解析请求(使用HTTP有线协议)。然后将请求路由到为与请求匹配的路由注册的处理程序。通常,仅请求的前缀必须与路由匹配,因此http://web.mit.edu/community/topic/arts.html
,/community
除非已注册更具体的(较长的前缀)路由,否则请求将被路由到处理程序。
然后,处理程序负责构造对请求的响应。
Oracle的Java实现包括一个Web服务器HttpServer
。您可以这样创建一个新的HttpServer
:
final int port = 8081; final HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
然后使用createContext
以下命令添加路由:
server.createContext("/play/", new HttpHandler() { public void handle(HttpExchange exchange) throws IOException { handlePlay(exchange); } });
在这里,我们正在制作一个实现该HttpHandler
接口的匿名实例,该接口是一种方法的接口handle()
。这handle()
是回调函数。每当传入请求以/play/
前缀开头时,服务器都会调用它。回调的参数是一个HttpExchange
对象,该对象为观察者方法提供有关请求的信息,并为更改器方法提供生成可返回到Web浏览器的响应的信息。
作为检查请求的示例,如果传入请求为http://localhost:8081/play/cello.wav
,则:
final String path = exchange.getRequestURI().getPath();
返回"/play/cello.wav"
,然后处理程序可以解析以提取"cello.wav"
应播放的文件名:
// remove /play/ from the start of the path to get the filename final String soundFilename = path.substring(exchange.getHttpContext().getPath().length());
作为生成响应的示例,处理程序应首先声明其返回的响应类型(HTML,纯文本或其他):
exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8"); exchange.sendResponseHeaders(200, 0);
然后使用交换对象的输出流编写响应:
PrintWriter out = new PrintWriter(exchange.getResponseBody(), true); out.println("playing " + soundFilename); exchange.close();
这HttpHandler
是回调的另一个示例:我们作为客户端将一段代码传递给模块HttpServer
,以便模块在发生事件(在这种情况下,与路由匹配的请求)发生时调用。
使用回调需要一种函数是一等的编程语言,这意味着它们可以像该语言中的任何其他值一样对待:作为参数传递,作为返回值返回,并存储在变量和数据结构中。
编程语言充满事情是不是一流的。例如,访问控制不是一流的-您不能将参数传递public
或private
作为参数传递给函数,也不能将其存储在数据结构中。的概念public
,并private
为你的程序的一个方面是Java提供没有办法指或操纵在运行时。同样,while
循环或if
语句也不是一流的。您不能单独引用该段代码,也不能在运行时对其进行操作。
在旧的编程语言中,只有数据才是一流的:内置类型(如数字)和用户定义的类型。但是在现代编程语言(如Python和Javascript)中,数据和函数都是一流的。一流的功能是一个非常强大的编程思想。最早使用它们的实用编程语言是Lisp,它是由MIT的John McCarthy发明的。但是,将函数作为一流值进行编程的想法实际上早于计算机,可追溯到Alonzo Church的lambda演算。lambda演算使用希腊字母λ定义新函数;这个词卡住了,不仅在Lisp及其后代中,而且在Python中,您都将其视为关键字。
Python的创建者Guido Von Rossum写了一篇有关设计原理的博客文章,该原理不仅导致了Python中的一流函数,而且也带来了一流方法:First-class Everything。
在Java中,唯一的一等值是原始值(int,布尔值,字符等)和对象引用。但是对象可以以方法的形式携带功能。因此,事实证明,在像Java这样不直接支持一流功能的面向对象的编程语言中,实现一流功能的方法是使用带有表示该功能的方法的对象。
实际上,我们已经看过几次了:
Runnable
传递给Thread
构造函数的对象是一流的函数void run()
。Comparator
你传递给一个有序集合(如对象SortedSet
)是一个一流的功能,int compare(T o1, T o2)
。ActionListener
我们传递给JButton
上面的对象是一等函数void actionPerformed(ActionEvent e)
。HttpHandler
我们传递给HttpServer
上面的对象是一等函数void handle(HttpExchange exchange)
。这种设计模式称为功能对象,即旨在表示功能的对象。Java中的功能对象的规范是由一个接口(称为单一抽象方法(SAM)接口)给出的,因为它仅包含一个方法。
Java的lambda表达式语法提供了一种创建功能对象实例的简洁方法。例如,代替编写:
new Thread(new Runnable() { public void run() { System.out.println("Hello!"); } }).start();
我们可以使用lambda表达式:
new Thread(() -> { System.out.println("Hello!"); }).start();
在Lambda表达式的Java教程页面上,阅读Lambda****表达式的语法。
这里没有魔术:Java仍然没有一流的功能。因此,仅当Java编译器可以验证两件事时,才可以使用lambda:
Thread
构造函数采用a Runnable
,因此它将推断类型必须为Runnable
。Runnable
实际上只有一个方法—void run()
因此编译器知道lambda主体中的代码属于run
新Runnable
对象的方法主体。Lambda表达式
在系统中使用回调不可避免地会迫使程序员考虑并发性,因为控制流不再在您的控制之下。您的回调可能随时被调用,在某些系统中,它可能是从与客户端最初来自的线程不同的线程中调用的。
一旦创建了GUI对象,Java的图形用户界面库就会自动创建一个线程。该事件处理线程与程序的主线程不同。它运行从鼠标和键盘读取的事件循环,并调用侦听器回调。
同样,HttpServer
该类创建一个新线程来侦听传入的连接并解析其HTTP请求,然后调用路由处理程序回调。
因此,即使用户看不到其他线程,这两个系统也是并发的。