Raku 提供了丰富的内置语法来定义和使用类。它使编写类在大多数情况下变得富有表现力和简洁,但也提供了机制来涵盖罕见的边缘情况。
快速概述§
让我们从一个例子开始,以概述
my = Rectangle.new(length => 2, width => 3);say .area(); # OUTPUT: «6»
我们使用 class 关键字定义一个新的 Rectangle 类。它有两个 属性,$!length
和 $!width
,使用 has 关键字引入。两者都默认为 1
。只读访问器方法会自动生成。(注意声明中的 .
而不是 !
,它会触发生成。助记符:!
类似于一扇关闭的门,.
类似于一扇打开的门。)
名为 area
的 方法 将返回矩形的面积。
很少需要显式地编写构造函数。一个自动继承的默认构造函数,称为 new,将自动从传递给构造函数的命名参数初始化属性。
任务示例§
作为更详细的示例,以下代码片段实现了一个依赖项处理程序。它展示了自定义构造函数、私有和公共属性、方法以及签名的各个方面。代码并不多,但结果却很有趣且有用。它将在以下各节中用作示例。
my =Task.new(,Task.new(,Task.new(,Task.new(),Task.new()),Task.new()));.perform();
类§
Raku,与许多其他语言一样,使用 class
关键字来定义一个类。后面的代码块可以包含任意代码,就像任何其他代码块一样,但类通常包含状态和行为声明。示例代码包括属性(状态),通过 has
关键字引入,以及行为,通过 method
关键字引入。
属性§
在 Task
类中,代码块中的前三行都使用 has
声明器声明属性(在其他语言中称为 字段 或 实例存储)。就像 my
变量无法从其声明范围之外访问一样,属性也永远无法从类之外直接访问(这与许多其他语言形成对比)。这种 封装 是面向对象设计的主要原则之一。
Twigil $!
§
第一个声明指定了回调的实例存储(即要调用的代码片段,以执行对象表示的任务)
has is built;
&
符号表示此属性代表可调用内容。!
字符是一个 twigil 或辅助符号。twigil 是变量名称的一部分。在本例中,!
twigil 强调此属性对类是私有的。该属性被 封装。私有属性默认情况下不会被默认构造函数设置,这就是我们添加 is built
特性以允许这样做的原因。助记符:!
看起来像一扇关闭的门。
第二个声明也使用了私有 twigil
has Task is built;
但是,此属性表示一个项目数组,因此它需要 @
符号。这些项目分别指定了在完成当前任务之前必须完成的任务。此外,此属性上的类型声明表明该数组只能包含 Task
类(或其某个子类)的实例。
Twigil $.
§
第三个属性表示任务的完成状态
has Bool ;
这个标量属性(带有 $
符号)的类型为 Bool
。它使用 .
符号而不是 !
符号。虽然 Raku 对属性强制封装,但它也免去了你编写访问器方法的麻烦。用 .
替换 !
既声明了一个私有属性,也声明了一个以属性命名的访问器方法。在本例中,属性 $!done
和访问器方法 done
都已声明。就好像你写了
has Bool ;method done()
请注意,这与某些语言允许的声明公共属性不同;你实际上得到了一个私有属性和一个方法,而无需手动编写方法。如果你将来需要执行比返回值更复杂的操作,可以自由地编写自己的访问器方法。
is rw
特性§
请注意,使用 .
符号创建了一个方法,该方法将提供对属性的只读访问。如果对象的用户应该能够重置任务的完成状态(也许是为了再次执行它),你可以更改属性声明
has Bool is rw;
is rw
特性会导致生成的访问器方法返回一个容器,以便外部代码可以修改属性的值。
is built
特性§
has is built;
默认情况下,私有属性不会被默认构造函数自动设置。(毕竟它们是私有的。)在上面的例子中,我们希望允许用户提供初始值,但保持属性的私有性。is built
特性允许这样做。
也可以用它对公共属性做相反的事情,即阻止它们被用户提供的 value 自动初始化,但仍然生成访问器方法
has is built(False);
上面的声明确保你无法构建已完成的任务,但仍然允许用户查看任务是否已完成。
is built
特性是在 Rakudo 2020.01 版本中引入的。
is required
特性§
默认情况下,在初始化期间为属性提供值是可选的。在任务示例中,这对于所有三个属性都是有意义的,即 &!callback
、@!dependencies
和 $.done
属性。但假设我们想添加另一个属性 $.name
,它保存任务名称,并且我们希望强制用户在初始化时提供值。我们可以按如下方式做到这一点
has is required;
默认值§
你也可以为属性提供默认值(这对于有和没有访问器的属性都适用)
has Bool = False;
赋值在对象构建时执行。右侧在此时进行评估,甚至可以引用之前的属性
has Task ;has = not ;
可写属性可以通过可写容器访问
say (a-class.new.an-attribute = "hey"); # OUTPUT: «hey»
此属性也可以使用 .an-attribute
或 .an-attribute()
语法访问。另请参阅 类上的 is rw
特性,了解有关如何在整个类上使用它的示例。
类变量§
类声明还可以包含类变量,用 my
或 our
声明,这些变量的值由所有实例共享,可用于诸如计算实例数量或任何其他共享状态等操作。因此,类变量的作用类似于其他编程语言中已知的静态变量。它们看起来与普通(非类)词法变量相同(实际上它们是相同的)
is Stris Str-with-IDsay Str-with-ID.new(string => 'First').ID; # OUTPUT: «1»say Str-with-ID.new(string => 'Second').ID; # OUTPUT: «2»say Str-with-ID-and-tag.new( string => 'Third', tag => 'Ordinal' ).ID; # OUTPUT: «3»say ::our-counter; # OUTPUT: «3»
类变量由所有子类共享,在本例中为Str-with-ID-and-tag
。此外,当使用包范围的our
声明符时,变量可以通过其完全限定名 (FQN) 访问,而词法范围的my
变量是“私有”的。这与my
和our
在非类上下文中表现出的行为完全相同。
在本例中,单例模式的实现使用类变量来保存实例。
类属性也可以使用一个辅助符号声明,类似于实例属性,如果属性是公开的,则会生成只读访问器。默认值按预期工作,并且只分配一次。
方法§
属性赋予对象状态,而方法赋予对象行为。回到我们的Task
示例。让我们暂时忽略new
方法;它是一种特殊类型的。考虑第二个方法add-dependency
,它将一个新的任务添加到任务的依赖列表中。
method add-dependency(Task )
在很多方面,这看起来很像一个sub
声明。但是,有两个重要的区别。首先,将此例程声明为方法会将其添加到当前类的所有方法列表中,因此Task
类的任何实例都可以使用.
方法调用运算符调用它。其次,方法将它的调用者放入特殊变量self
中。
该方法本身接受传递的参数(必须是Task
类的实例),并将其push
到调用者的@!dependencies
属性中。
perform
方法包含依赖项处理器的主要逻辑。
method perform()
它不接受任何参数,而是使用对象的属性。首先,它通过检查$!done
属性来检查任务是否已完成。如果是,则无需执行任何操作。
否则,该方法将执行所有任务的依赖项,使用for
结构迭代@!dependencies
属性中的所有项。此迭代将每个项(每个都是Task
对象)放入主题变量$_
中。在不指定显式调用者的情况下使用.
方法调用运算符会使用当前主题作为调用者。因此,迭代结构会在@!dependencies
属性中每个Task
对象上调用.perform()
方法。
在所有依赖项都完成后,是时候通过直接调用&!callback
属性来执行当前Task
的任务了;这就是括号的作用。最后,该方法将$!done
属性设置为True
,以便后续对该对象的perform
调用(例如,如果此Task
是另一个Task
的依赖项)不会重复该任务。
私有方法§
与属性一样,方法也可以是私有的。私有方法用前缀感叹号声明。它们用self!
后跟方法名来调用。在以下MP3TagData
类的实现中,从 mp3 文件中提取ID3v1 元数据,方法parse-data
、can-read-format
和trim-nulls
是私有方法,而其余方法是公共方法。
要调用另一个类的私有方法,调用者必须得到被调用者的信任。信任关系用trusts
声明,要信任的类必须已经声明。调用另一个类的私有方法需要该类的实例和方法的完全限定名 (FQN)。信任关系还允许访问私有属性。
C.new.yours-to-use(); # the context of this call is GLOBAL, and not trusted by CB.new.i-am-trusted();
信任关系不受继承的影响。要信任全局命名空间,可以使用伪包GLOBAL
。
构造§
到目前为止描述的对象构造机制足以满足大多数用例。但如果实际上需要比这些机制允许的更多地调整对象构造,那么了解对象构造的更详细工作原理是很有必要的。
在构造函数方面,Raku 比许多语言都要自由。构造函数是指任何返回类实例的函数。此外,构造函数是普通方法。您从基类 Mu
继承了一个名为 new
的默认构造函数,但您可以自由地覆盖 new
,就像 Task 示例中所做的那样。
method new(, *)
bless§
Raku 中的构造函数与 C# 和 Java 等语言中的构造函数之间最大的区别在于,Raku 构造函数不是在某个已经神奇地创建的对象上设置状态,而是自己创建对象。它们通过调用 bless 方法来实现,该方法也是从 Mu
继承的。bless
方法期望一组命名参数,以提供每个属性的初始值。
示例中的构造函数将位置参数转换为命名参数,以便该类可以为其用户提供更友好的构造函数。第一个参数是回调(将执行任务的函数)。其余参数是依赖的 Task
实例。构造函数将这些参数捕获到 @dependencies
贪婪数组中,并将它们作为命名参数传递给 bless
(请注意,:&callback
使用变量的名称(减去符号)作为参数的名称)。应该避免在构造函数中放置除重新格式化参数之外的逻辑,因为构造函数方法不会递归调用父类。这与 Java 等语言不同。
将 new
声明为 method
而不是 multi method
会阻止访问默认构造函数。+因此,如果您打算保留默认构造函数,请使用 multi method new
。
TWEAK
§
在 bless
从传递的值初始化类属性后,它将依次为继承层次结构中的每个类调用 TWEAK
。TWEAK
将接收传递给 bless 的所有参数。自定义初始化逻辑应该放在这里。
请记住始终将 TWEAK
设为 子方法,而不是普通的 method
。如果在一个类层次结构中,一个类包含一个 TWEAK
方法(声明为 method
而不是 submethod
),那么该方法将被继承到其子类,因此在子类的构造过程中将被调用两次!
BUILD
§
可以禁用自动属性初始化,并自行执行属性初始化。为此,需要编写一个自定义的 BUILD
子方法。但是,需要注意并考虑几个边缘情况。这在 对象构造参考 中有详细说明。由于使用 BUILD
的难度,建议仅在上述其他方法都不够用时才使用它。
销毁§
Raku 是一种垃圾收集语言。这意味着通常不需要关心清理对象,因为 Raku 会自动执行此操作。但是,Raku 不会对何时清理给定对象做出任何保证。它通常只在运行时需要内存时才进行清理运行,因此我们无法依赖它何时发生。
要运行在清理对象时执行的自定义代码,可以使用 DESTROY
子方法。例如,它可以用来关闭句柄或供应,或删除不再使用的临时文件。由于垃圾收集可能发生在程序运行时的任意点,甚至可能发生在不同线程中某个完全无关的代码段的中间,因此我们必须确保在 DESTROY
子方法中不假设任何上下文。
my = 0;my ;for 1 .. 6000say "DESTROY called $in_destructor times";
这可能会打印类似 DESTROY called 5701 times
的内容,并且可能只在我们踩过 Foo
的前几个实例数千次后才会生效。我们也不能依赖销毁的顺序。
与 TWEAK
相同:确保始终将 DESTROY
声明为 submethod
。
使用我们的类§
创建类之后,您可以创建类的实例。声明自定义构造函数提供了一种简单的方法来声明任务及其依赖项。要创建一个没有依赖项的单个任务,请编写
my = Task.new();
前面部分解释了声明类 Task
会在命名空间中安装一个类型对象。此类型对象是类的“空实例”,具体来说是没有任何状态的实例。您可以对该实例调用方法,只要它们不尝试访问任何状态;new
就是一个例子,因为它创建了一个新对象,而不是修改或访问现有对象。
不幸的是,晚餐永远不会神奇地发生。它有依赖的任务
my =Task.new(,Task.new(,Task.new(,Task.new(),Task.new()),Task.new()));
注意自定义构造函数和合理的空格使用如何使任务依赖项清晰。
最后,perform
方法调用递归地按顺序调用各种其他依赖项的 perform
方法,从而给出输出
making some money going to the store buying food cleaning kitchen making dinner eating dinner. NOM!
关于类型的说明§
声明类会创建一个新的类型对象,默认情况下,它会被安装到当前包中(就像使用our
范围声明的变量一样)。此类型对象是类的“空实例”。例如,Int
和 Str
等类型引用了 Raku 内置类之一的类型对象。可以对这些类型对象调用方法。因此,对类型对象调用new
方法没有什么特别之处。
您可以使用.DEFINITE
方法来确定您拥有的究竟是实例还是类型对象
say Int.DEFINITE; # OUTPUT: «False» (type object)say 426.DEFINITE; # OUTPUT: «True» (instance);say Foo.DEFINITE; # OUTPUT: «False» (type object)say Foo.new.DEFINITE; # OUTPUT: «True» (instance)
在函数签名中,可以使用所谓的类型“表情符号”来仅接受实例或类型对象
multi foo (Int)multi foo (Int)say foo Int; # OUTPUT: «It's a type object!»say foo 42; # OUTPUT: «It's an instance!»
继承§
面向对象编程提供继承的概念作为代码重用的机制之一。Raku 支持一个类从一个或多个类继承的能力。当一个类从另一个类继承时,它会通知方法调度程序遵循继承链以查找要调用的方法。这适用于通过method
关键字定义的标准方法,也适用于通过其他方式生成的方法,例如属性访问器。
is Employee
现在,任何类型为 Programmer 的对象都可以使用在 Employee 类中定义的方法和访问器,就好像它们来自 Programmer 类一样。
my = Programmer.new(salary => 100_000,known_languages => <Raku Perl Erlang C++>,favorite_editor => 'vim');say .code_to_solve('halting problem')," will get \$ ";# OUTPUT: «Solving halting problem using vim in Raku will get $100000»
覆盖继承的方法§
类可以通过定义自己的方法和属性来覆盖父类定义的方法和属性。下面的示例演示了Baker
类覆盖Cook
的cook
方法。
is Employeeis Cookmy = Cook.new(utensils => <spoon ladle knife pan>,cookbooks => 'The Joy of Cooking',salary => 40000);.cook( 'pizza' ); # OUTPUT: «Cooking pizza»say .utensils.raku; # OUTPUT: «["spoon", "ladle", "knife", "pan"]»say .cookbooks.raku; # OUTPUT: «["The Joy of Cooking"]»say .salary; # OUTPUT: «40000»my = Baker.new(utensils => 'self cleaning oven',cookbooks => "The Baker's Apprentice",salary => 50000);.cook('brioche'); # OUTPUT: «Baking a tasty brioche»say .utensils.raku; # OUTPUT: «["self cleaning oven"]»say .cookbooks.raku; # OUTPUT: «["The Baker's Apprentice"]»say .salary; # OUTPUT: «50000»
因为调度程序会在它移动到父类之前看到Baker
上的cook
方法,所以会调用Baker
的cook
方法。
多重继承§
如前所述,一个类可以从多个类继承。当一个类从多个类继承时,调度程序知道在查找要搜索的方法时查看这两个类。Raku 使用C3 算法 来线性化多重继承层次结构,这比深度优先搜索更适合处理多重继承。
is Programmer is Cookmy = GeekCook.new(books => 'Learning Raku',utensils => ('stainless steel pot', 'knife', 'calibrated oven'),favorite_editor => 'MacVim',known_languages => <Raku>);.cook('pizza');.code_to_solve('P =? NP');
现在,Programmer 和 Cook 类提供的所有方法都可以在 GeekCook 类中使用。
虽然多重继承是一个有用的概念,可以了解和偶尔使用,但重要的是要理解还有更多有用的 OOP 概念。当使用多重继承时,最好考虑一下设计是否可以通过使用角色来更好地实现,角色通常更安全,因为它们强制类作者明确解决冲突的方法名称。有关角色的更多信息,请参见角色。
also
§
可以在类声明体中通过在is
特质前加上also
来列出要继承的类。这对于角色组合特质does
也适用。
;;
内省§
内省是收集程序中某些对象信息的过程,不是通过阅读源代码,而是通过查询对象(或控制对象)的某些属性,例如它的类型。
给定一个对象$o
和前面几节中的类定义,我们可以问它一些问题
my Programmer .= new;if ~~ Employee ;say ~~ GeekCook ?? "It's a geeky cook" !! "Not a geeky cook";say .^name;say .raku;say .^methods(:local)».name.join(', ');
输出可能如下所示
It's an employee Not a geeky cook Programmer Programmer.new(known_languages => ["Perl", "Python", "Pascal"], favorite_editor => "gvim", salary => "too small") code_to_solve, known_languages, favorite_editor
前两个测试都与类名进行智能匹配。如果对象属于该类或其继承类,则返回True
。因此,所讨论的对象属于Employee
类或其继承类,但不属于GeekCook
类。
调用$o.^name
告诉我们$o
的类型;在本例中为Programmer
。
$o.raku
返回一个可以作为 Raku 代码执行的字符串,并重现原始对象$o
。虽然这在所有情况下都不完美,但对于调试简单对象非常有用。 [1]
使用.^
而不是单个点调用方法的语法意味着它实际上是对其元类的调用,元类是一个管理Programmer
类属性的类——或者任何您感兴趣的其他类。这个元类也支持其他内省方式
say .^attributes.join(', ');say .^parents.map().join(', ');
最后,$o.^name
调用元对象上的name
方法,不出所料地返回类名。
给定一个对象$mp3
和来自私有方法部分的MP3TagData
类定义,我们可以使用.^methods
查询其公共方法
my = MP3TagData.new(filename => 'football-head.mp3');say .^methods(:local);# OUTPUT: (TWEAK filename title artist album year comment genre track version# type Submethod+{is-hidden-from-backtrace}.new)
$mp3.^methods(:local)
生成一个可以在$mp3
上调用的Method
列表。:local
命名参数将返回的方法限制为在MP3TagData
类中定义的方法,并排除继承的方法;MP3TagData
没有继承任何类,因此提供:local
没有任何区别。
要检查类型对象(或实例对象)是否实现某个公共方法,请使用.^find-method
元方法,如果存在,它将返回方法对象。否则,它将返回Mu
。
say .^find_method('name'); # OUTPUT: «(Mu)»say .^find_method('artist'); # OUTPUT: «artist»
类型对象也可以内省其私有方法。但是,公共方法和私有方法不使用相同的 API,因此必须使用不同的元方法:.^private_methods
和.^find_private_method
。
say .^private_methods; # OUTPUT: «(parse-data can-read-format trim-nulls)»say .^find_private_method('parse-data'); # OUTPUT: «parse-data»say .^find_private_method('remove-nulls'); # OUTPUT: «(Mu)»
内省对于调试和学习语言和新库非常有用。当一个函数或方法返回一个你不知道的对象时,通过使用.^name
找到它的类型,使用.raku
查看它的构造配方,等等,你就能很好地了解它的返回值是什么。使用.^methods
,你可以学习如何使用这个类。
但也有其他应用。例如,一个将对象序列化为一堆字节的例程需要知道该对象的属性,它可以通过内省来找出这些属性。
覆盖默认的 gist 方法§
某些类可能需要自己的gist
版本,它覆盖了它们在被调用时打印的简洁方式,以提供类的默认表示。例如,异常可能只想写入payload
,而不是完整的对象,这样更清楚地看到发生了什么。但是,这并不局限于异常;你可以对每个类都这样做
my = Cook.new(utensils => <spoon ladle knife pan>,cookbooks => ['Cooking for geeks','The French Chef Cookbook']);say Cook.gist; # OUTPUT: «⚗Cook⚗»say .gist; # OUTPUT: «⚗ Cooks with spoon ‣ ladle ‣ knife ‣ pan using «Cooking for geeks» and «The French Chef Cookbook»»
通常你会想要定义两个方法,一个用于类,另一个用于类实例;在本例中,类方法使用炼金术符号,而实例方法(定义在它下面)聚合了我们关于厨师的数据,以叙述的方式显示它。
一个实用的内省示例§
当创建一个新类时,有时需要以公共方法的形式更方便地访问信息丰富(且安全的)内省。例如,以下类用于保存 CSV 电子表格中记录行的属性,其中标题行定义其字段(属性)名称。
unit ;has ;has ;#...more fields (attributes)...method fields(--> List)method values(--> List)
我们使用一个简单的 CSV 文件,其内容为
last, first #...more fields... Wall, Larry Conway, Damian
加载第一条记录并显示其内容
my = CSV-Record.new: :, :;say .fields.raku; # OUTPUT: «["last", "first"]»say .values.raku; # OUTPUT: «["Wall", "Larry"]»
请注意,在实际应用中,我们会设计该类,使其将 fields
列表作为常量,因为其值对于所有类对象都是相同的。
constant = <last first>;method fields(--> List)
使用内省方法获取属性名称的缺点包括处理时间和功耗略高,以及可能需要删除符号和 twigil 以便公开展示。