Io 编程/初学者指南/对象
Io 是一种动态类型的、动态分派的、面向对象的编程语言,非常类似于 Self 和 Smalltalk。到目前为止,我们主要处理的是语言中最原始的东西。
然而,现在是读者学习对象及其在 Io 环境中的使用方法的时候了。
对象,在计算机编程的语境中,是概念或有形事物的抽象,拟人化地说,你可以告诉它做什么。例如,这条语句
Io> writeln("Hello world!")
可以看作是告诉计算机在屏幕上写一行。面向对象的语言使我们能够接受这个概念并将其提升到对我们需求更有用的水平。如果你回顾你的第一个 Io 程序,其中有一行是这样的
you := File standardInput readLine
该表达式按以下顺序求值
- 首先,File 被求值。我们告诉计算机给我们一个 File,无论它是什么(我们现在不必关心它)。
- 接下来,我们告诉File给我们标准输入。
- 接下来,我们告诉那个东西读取一行文本。
- 最后,我们将其分配给你。
如果我们让计算机本身给我们一个标准输入对象,会发生什么?
Io> standardInput
·
Exception: Object does not respond to 'standardInput' --------- Object standardInput Command Line 1
我们看到的是一个错误消息。它由几个部分组成,你应该熟悉它们。当你开始时,你会看到很多这样的东西。
- 第一行(Exception: Object does not...)告诉我们Object没有响应 standardInput 消息。
- 最下面部分是一个回溯,它告诉我们错误发生在哪里。这在试图找出哪里出了问题时非常宝贵。
到目前为止,我们已经看到了如何使用对象将一些命名空间划分为有用的库。但是,我们还没有看到任何东西;对象不仅仅是库的别称。
为了看到如何使用对象使我们的程序更模块化,了解“它们如何工作”是有用的。
可以将对象视为这个东西,它可以执行命令。它可以执行哪些命令由其槽位中出现的内容决定。
例如,让我们自己创建一个小对象。面向对象的等效于臭名昭著的hello world程序莫过于
Io> Account := Object clone Io> Account balance := 0 Io> Account withdraw := method(amount, self balance = self balance - amount) Io> Account deposit := method(amount, self balance = self balance + amount)
为了简洁起见,我在这里省略了 Io 解释器的输出。但我们刚刚做了
- 创建了一个Account 对象。
- 配置对象使之具有零的余额。
- 教 Account 对象如何提取东西(大概是现金)。
- 教 Account 对象如何存入东西。
我们可以很容易地查询此对象的当前账户余额
Io> Account balance ==> 0
这是因为执行Account足以告诉解释器,“好的,我现在运行在Account这个东西的上下文中”。下一条指令是balance。解释器看到Account显然实现了balance,因此执行它。在本例中,它的值为零。因此,==> 0
报告。
另一种看待它的方式,实际上也是首选方式,是思考,“嘿!Account!你在那里!Balance,现在!”。换句话说,我们告诉Account对象给我们余额。如你所见,这与File standardInput readLine并没有太大区别——后者只是命令更多,但机制完全相同。
接下来,让我们在我们的账户中存入一些现金。
Io> Account deposit(100) ==> 100
这产生了意外的结果——为什么它返回了 100,而不是nil?这是因为它计算的最后一件事情是self balance + amount,当你考虑它的时候,它计算为 0+100。
仔细看看——当我们执行deposit(100)时,deposit 槽位在 Account 对象中被获取,就像上面的balance一样。与balance一样,解释器执行这个槽位。但是,它不仅仅是一个未修饰的值,它是一个方法。还记得我说过我们很快就会讲到方法吗?
- 观察
如果你试图告诉对象做一些事情,那么你显然是在向它们发送消息,这些对象知道如何解释(以某种方式)这些消息。在本例中,balance和deposit都是消息。但是,解释这些消息的方法完全取决于对象本身。因此得名方法。现在我们已经解开了其中一个谜团,我们又引入了另一个——我们稍后将讲到方法与过程的意义。
- 注意
从数学角度来说,deposit不是一个函数,因为它有称为副作用的东西。这意味着评估消息将导致一些状态变化,这些状态变化会持续到消息评估结束之后。纯数学函数从不这样做。现在你知道为什么我们也不将deposit称为函数了!
在deposit内部运行时,我们看到我们需要用self balance来引用我们的余额。在这里,self显然指的是运行方法的对象,因此它允许我们访问我们对象的數據。
- 观察
默认情况下,所有槽位都对最内层词法范围局部。换句话说,没有全局变量。
- 练习
试着弄清楚Account withdraw(50)
是如何工作的!
我保证,这是我们在看到对象如何真正变得有用之前最后的偏离。我们需要了解继承是如何工作的,因为它是在 Io 中的基石。
再次看看我们上面的Account对象,对象的创建涉及克隆Object。在 Io 中,一切最终都是某种Object,但如果你正在创建一个真正新颖独特的东西,你可以直接克隆Object。
这句话的意思是,好吧,Account是一个Object。但是,我们继续专门化这个对象,它有余额,以及一些调整余额的方法。
但是,与 Io 中的所有事物一样,这个非常简单的例子比表面上看起来要复杂。考虑一下,如果我们总是通过发送消息来指挥对象,那么self被发送到哪里呢?
如果你回答Object,那么你不完全正确。还记得我说过,默认情况下,Io 中的所有东西都是局部的吗?方法变量也不例外。Io 查找事物顺序如下
- 首先在方法本身中查找。没有名为self的局部变量。
- 接下来在Account对象中查找。不,这里也没有。
- 接下来在Object中查找。啊哈!
每个对象都实现了一系列原型,它使用这些原型作为灵感来源,如果你愿意的话,来确定如何处理消息。如果你向对象发送一条它不知道如何处理的消息,那么它会咨询它的原型以找出如何处理。如果,并且仅当它在这项任务中失败时,你才会收到那个臭名昭著的错误消息,Object does not respond to 等等。
现在我们已经了解了对象的本质及其如何从其他对象继承,让我们看看如何将它付诸实践。
首先,假设你想要多个帐户。有许多不同类型的帐户,但我们将坚持使用基本储蓄帐户。这些帐户随着时间的推移会积累利息。但是,来自不同银行的帐户会以不同的利率积累利息。我们如何管理这种额外的复杂性?
首先,我们需要一个储蓄帐户
Io> SavingsAccount := Account clone
正如我们在这里看到的,我们创建了一个新对象,它依赖 Account 作为其行为的灵感来源。我们可以验证它没有任何自己的方法
Io> SavingsAccount slotNames foreach(println) type ==> type
好吧,它有一个槽位type,当调用它时会返回SavingsAccount
。但除此之外没有其他东西。然而,我们仍然可以像使用普通帐户对象一样使用它
Io> SavingsAccount balance ==> 0
储蓄帐户通常会有一些利率
Io> SavingsAccount interestRate := 0.045
有了它,我们可以估计我们将在年底有多少
Io> SavingsAccount yearEndEstimate := method( )-> self balance * self interestRate + self balance )-> )
因此,当它执行时,搜索顺序是首先尝试在方法本身中找到balance,然后在SavingsAccount中找到,然后在Account中找到,在那里它实际上被定义。
假设我们需要为你的家人开设银行帐户。我们现在可以这样做
Io> MomsAccount := SavingsAccount clone Io> DadsAccount := SavingsAccount clone Io> WifesAccount := SavingsAccount clone
所以,我们应该能够独立地跟踪不同的帐户
注意:本节描述的行为与当前 Io 版本的行为不符。有关详细信息,请参见讨论页面。
Io> MomsAccount deposit(450) Io> DadsAccount deposit(450) Io> WifesAccount balance ==> 900
哇,这是怎么回事?看起来所有的存款都进入了一个余额!
- 练习
你能弄清楚为什么吗?
在面向对象编程社区中,你经常会听到这句话,“实例化一个类 x 的对象”,其中 x 是他们所指的任何类。例如,要创建一个新列表,你可能会看到关于某种 列表类 的提及。
Io 没有类,而这正是前一节中所有关于平衡的描述都归结为一个的原因。由于 没有 任何派生对象实现它自己的 balance 消息,它假设对象的原型知道如何处理它。事实证明,这种假设是错误的。
那么,我们如何实例化一个对象,以便每次需要时它都会创建自己的 balance 槽位?我们使用 init 方法来实现。
Io> SavingsAccount init := method(self balance := 0)
通过这样做,我们已经将 SavingsAccount 从任何普通的对象转变为 一个类。我们现在可以像对待 SavingsAccount 的 类型 一样,整天对 SavingsAccount 对象进行逻辑推理。事实上,它们正是这样。
我们现在可以重新实例化我们家庭的账户。
Io> MomsAccount := SavingsAccount clone Io> DadsAccount := SavingsAccount clone Io> WifesAccount := SavingsAccount clone
这里发生的事情是,在克隆过程结束后,会发送 SavingsAccount init 以确保对象被正确初始化。
Io> MomsAccount deposit(450) Io> DadsAccount deposit(450) Io> WifesAccount balance ==> 0
请注意,我们不需要手动实现或重新实现 withdraw 或 deposit 方法。我们对基本 Account 对象知道如何处理取款和存款的假设仍然成立。唯一错误的是从哪里取款或存入。因此,通过创建一个意识到这种混淆的账户类型,我们可以在执行任何进一步的程序代码之前,有机会澄清。
应该指出,所有类都是原型——供另一个对象使用的模板。但是,并非所有原型都是类。正如我们所见,Account 也是一个原型,但严格来说,它不是一个类。是 SavingsAccount 及其 init 方法保留了那些让我们可以将它视为 类型 而不是 事物 的假设。然而,如果我们愿意,我们可以自由地根据 事物 来定义 类型,就像我们在本例中所做的那样。在基于类的面向对象语言中,这种灵活性是不可能的。
编写面向对象程序时,要记住的一点是,你无需第一次就做到完美。上面我说过,我们可以有多种类型的账户,每种账户都有不同的利率,但是到目前为止,软件的演变方式并不容易实现。所以,让我们修复这些问题。
Io> Account init := method(self balance := 0) Io> SavingsAccount removeSlot("init") Io> AccountWithInterest := Account clone Io> AccountWithInterest yearEndEstimate := SavingsAccount getSlot("yearEndEstimate") Io> SavingsAccount removeSlot("yearEndEstimate") Io> SavingsAccount setProto(AccountWithInterest)
通过少量语句,我们刚刚重新设计了整个类型层次结构。现在,Account 是(所谓的)基类,而 SavingsAccount 现在只是 AccountWithInterest 的一种特殊类型。依赖于 SavingsAccount 的软件应该仍然可以运行,因为我们并没有从根本上改变 SavingsAccount 对象的 行为。
现在,我们可以定义什么是 CheckingAccount。
Io> CheckingAccount := AccountWithInterest clone Io> CheckingAccount interestRate := 0.015
这就是人们很少使用支票账户来攒钱的原因。
Io> MomsChecking := CheckingAccount clone ...etc...
显然,这是一个非常简单的例子,但它向你展示了对象、原型和类之间关系的改变,可以在程序运行期间进行修改,甚至可以是在程序正在运行时进行修改。它们不像其他语言那样静态。
话虽如此,我想要明确一点,这种编程非常适合 探索性工作,但你不应该将它用于生产代码。如果你像这样“热修复”软件,请确保相应地修复程序的源代码,以便将来不再需要这种热修复。
实现这一目标的一种方法是测试驱动开发。但是,这个过程超出了本书的范围。在这里,我的任务是教你如何在 Io 中编写软件。它并不是 如何设计软件。如果你想了解更多关于这方面的信息,请参阅 [1]。