Raku 面向对象简单入门

序言

介绍

本教程最多只关注 Raku 中的面向对象编程(OOP)的基本知识。因此,对语句/表达式、变量、条件、循环、子例程(函数)等有一个基本的了解是很重要的,如果不在 Raku 中,至少在另一种编程语言中是这样的。此外,您至少应该对类、属性和方法有一般的了解。作为对 Raku 的介绍,我强烈推荐 Raku introduction。下一步是 Raku 文档。

确保你已经设置好了 Raku 编译器。如果你还没有设置好,请看这里。 从这里开始,你可能会厌倦代词“我们”,但它的使用是经过深思熟虑的。这是一个教程,希望你能跟上。所以,是的,我们在一起工作,你应该做好准备。顺便说一下,本教程是冗长的,这是故意的,但也是手把手教程的副产品。

问题陈述

我们将从现实生活中的问题开始,并尝试以面向对象的方式对其进行建模。问题陈述如下: 在她的数学101课程中,一位教授记录了三个作业(2个作业和1个考试)的分数,按照学生交作业的顺序:

Bill Jones:1:35
Sara Tims:2:45
Sara Tims:1:39
Bill Jones:1:42
Bill Jones:E1:72

在一个名为 MATH-101 的简单文本文件中。您可以假设有更多的学生,而这只是数据文件的一个代表性块。在这个文件中,每行记录学生的姓名、作业编号(作业编号为1,2,第一次考试为E1)和学生获得的原始分数。 教授使用另一个扩展名为 .std 的文件存储她课程的学生名单:

Bill Jones
Ana Smith
Sara Tims
Frank Horza

除了 MATH-101,这位教授还教其他课程,并设计了一个扩展名为 .cfg 的配置文件来存储给定课程的配置格式。她这样做的目的是在她的其他课程中也使用它。配置文件格式指定了作业的类型、作业编号、作业的总分以及作业对最终课程成绩的贡献。她的数学101课程的 .cfg 文件如下:

Homework:1:50:25
Homework:2:50:25
Exam:1:75:50

您的任务是创建一个名为 report.p6 的程序。该程序生成一个报告,其中列出了班级中每个学生的姓名、每次作业的分数和最终成绩。该程序应该假设具有扩展名 .cgf 和 .std 的文件在执行该程序的目录中可用。另一方面,包含学生成绩的文件必须通过命令行传递给程序。为了简单起见,您可以假设每个文件都是根据课程命名的。对于她的数学101课程,教授会有以下的文件: MATH-101, MATH-101.std 和 MATH-101.cfg,还有脚本 report.p6。

分析

如果我们看问题陈述,我们可以把所有的东西分成三类:课程,学生和作业。就目前而言,每个类别都可以被视为具有状态和行为的类。我们将从最简单的类别,作业类别,到最一般的类别,课程类别。为了做到这一点,我们首先学习 Raku 中类的定义。

Raku 类

类定义

在 Raku 中,类是用 class 关键字定义的,通常后面跟着类名(通常以首字母大写形式)。

class Name-of-class {

}

属性定义

所有的 Raku 属性默认都是私有的,这意味着它们只能在类中访问。属性是使用 has 关键字和 ! twigil 定义的。

class Name-of-class {
    has $!attribute-name;
}

属性也可以使用 . twigil。这个 twigil 声明,应该生成一个以属性命名的只读访问器方法:

class Name-of-class {
    has $.attribute-name; # $!attribute-name + attribute-name()
}

这等价于:

class Name-of-class {
    has $!attribute-name;
    
    method attribute-name { 
        return $!attribute-name;
    }
}

生成的访问器方法是只读的,因为属性默认是只读的(从类的外部)。为了允许通过访问器方法修改属性,必须向属性添加 is rw 特质。其他特质也可以用于属性。有关特质的更多信息,请参阅文档。

作业类

让我们从详细描述 Assignment 类所需的属性开始:

  • type——作业的类型(作业或考试)。
  • number——作业编号(1,2等)。
  • score——这个作业的分数。
  • raw——给定作业的最大点数。
  • contrib——作业对最终成绩的贡献。
  • adjusted-score——基于 scorerawcontrib 属性的格式化的分数。
  • config——一个包含课程配置文件的散列。

关于 config 哈希,每个赋值的信息将存储在一个数组中,这个数组将根据每个赋值(作业或考试)在数组中的赋值号进行索引。由于没有零赋值,这个槽将用于存储已处理的赋值总数。MATH-101.cfg 的 config 散列是这样的:

%(
    Homework => [ {total => 2}, (50, 25), (50, 25) ],
    Exam => [ {total => 1}, (75, 50) ],
)

这导致了下面的类:

class Assignment {
    # attributes with a read-only accessor
    has $.type;
    has $.number;
    has $.score;
    has %.config;   # given that a hash is used, the $ (scalar) is replaced
                    # with a % (hash).

    # private attributes hence the ! twigil.
    has $!raw;
    has $!contrib;
    has $!adjusted-score;
}

为了创建 Assignment 类的实例并初始化它,我们将命名参数传递给 Raku 提供的默认的 new 构造函数方法,并由所有类继承:

# create a new instance object and initialize its attributes. 
# The new constructor is called on Assignment, the type object of 
# the class Assignment.
my $assign01 = Assignment.new(
    type => 'Homework', # named argument 
    :number(2),         # Alternate colon-pair syntax for named arguments
    :score(45),
    :config(%(
        Homework => [ {total => 2}, (50, 25), (50, 25) ],
        Exam => [ {total => 1}, (75, 50) ])
    ),
 );

# accessing the instance object's attributes 
# through their accessor method:
say $assign01.type();     # OUTPUT: 'Homework'
say $assign01.number();   # OUTPUT: 2
say $assign01.score();    # OUTPUT: 45

注意: 如果属性是用 ! twigil 定义的,那么不能使用 new 构造函数方法来初始化它。正如前面提到的,这是由于属性是私有的,这意味着它不能从类外部访问,甚至不能通过new 构造函数访问。但是,这个默认行为可以用 BUILD 子方法重写。有关 BUILD 子方法的更多信息,请参阅文档

我们已经知道赋值的类型总是字符串,数字总是整数,调整后的分数是 rational 等等,所以我们也可以相应地键入属性:

class Assignment {
    has Str $.type;
    has Int $.number;
    has $.score;
    has %.config;

    has $!raw;
    has $!contrib;
    has Rat $!adjusted-score;
}

有关类型的更多信息,请参阅文档Assignment 类的行为在很大程度上取决于每个学生的数据,但我们对 Student 类的结构还一无所知。出于这个原因,我们将继续学习 Student 类,稍后再回到这个话题。

Student 类

Assignment 类类似,让我们从详细描述 Assignment 类将具有的属性开始:

  • name——表示学生名字的字符串。
  • assign-num——作业的数量(一个整数)。
  • assignments——我们希望将作业分成不同的类型(作业或考试),所以我们将使用哈希。每个键将指向它们各自的赋值对象的数组。
  • config——在 Assignment 类中描述的课程的配置文件。

这导致了下面的类:

class Student {
    has Str $.name;
    has Int $!assign-num = 0;
    has %!assignments;
    has %.config;
}

我们应该能够将作业附加到 Student 类的一个实例中,并从中获得作业、考试等等。这些行为表示类的行为,并通过使用方法来实现。

公共和私有方法

正如在 Raku 介绍中所述,“方法是对象的子例程,就像子例程一样,它们是打包一组功能的方法,它们接受参数,具有签名,可以定义为 multi。”

Raku 方法是使用 method 关键字定义的,它是在 invocant 上使用点(.)调用的。默认情况下,所有方法都是公共的。但是,方法可以通过在名称前面加上感叹号(!)来定义为私有的。在这种情况下,使用感叹号而不是点来调用它们。

有了这些知识,我们现在将向 Student 类添加一个 add-assignment 方法。这个方法需要作业的编号(1、2、3等,或者E1、E2等)和收到的分数。不会提供作业的类别,但我们可以使用编号来确定:

class Student {
    # same attributes as before.

    method add-assignment( $number is copy, $score ) {
        my $type;
       
        # determine the assignment type.
        if $number ~~ s/^E// {   # do replacement in place
            $type = 'Exam';      # to obtain the exam's number.
        }
        else {
            $type = 'Homework';
        }
    
        # coerce assignment number to an integer.
        $number .= Int;
        
        # create an Assignment object from available information.
        my $assign-obj = Assignment.new(
            type   => $type,
            number => $number,
            score  => $score,
            config => %!config,
        );
    
        # add assignment into its type indexed by its number.
        %!assignments{$type}[$number] = $assign-obj;
    
        # increment number of assignments by 1.
        $!assign-num++; 
    
    }
    
}

为了展示私有方法的创建,我们将在 add-assignment 方法中把创建 Assignment 对象外包给一个名为 create-assignment 的私有方法,该方法返回一个 Assignment 对象:

class Student {
    # same as before
    
    # notice the ! twigil before the method's name.
    method !create-assignment( Str $type, Int $number, $score ) { 
        return Assignment.new(
            type   => $type,
            number => $number,
            score  => $score,
            config => %!config,
        );
    }

    method add-assignment( $number is copy, $score ) {
        # same code as before.
  		        
        # create an Assignment object with this information.
        my $assign-obj = self!create-assignment($type, $number, $score);
        
        # same code as before.
    }
       
} 

正如您可能已经注意到的,self 关键字用于调用 add-assignment 方法中的 create-assignment 方法。self 是绑定到 invocant 的特殊变量,在方法内部可用。此变量可用于调用程序上的进一步方法。其内部方法调用如下:

  • self!method($arg) 用于私有方法。

  • self.method($arg) 用于公共方法。 $.method($arg) 是它的快捷形式。注意,方法参数(位置和命名)的冒号语法只支持在使用 self 时调用方法,而不支持快捷形式。所以:

  • self.method: arg1, arg2… is supported.

  • $.method: arg1, arg2… is not supported.

方法的签名总是传递 self 作为它的第一个参数。但是,我们可以通过提供第一个参数和一个冒号来为方法指定一个显式调用者。此参数将充当方法的调用方,并允许方法引用显式调用的对象。 例如:

class Person {
    has $.name = 'John';    # attributes can be set to default values.
    
    # here self refers to the object, albeit implicitly.
    method introduce() {
        say "Hi, my name's ", self.name(), "!";
        #                     ^^^^^^^^^^^ calling method on self
    }
    
    # here $person explicitly refers to the object.
    method alt-introduce( $person: ) {
        say "Hi, my name's ", $person.name(), "!";
        #                     ^^^^^^^^^^^^^^ calling method on $person
    }
}

Person.new.introduce();      # OUTPUT: Hi, my name's John!
Person.new.alt-introduce();  # OUTPUT: Hi, my name's John!

TWEAK 子方法

我们回到 Assignment 类。目前,类的使用者可以在创建 Assignment 对象时传递他们想要的任何东西。出于这个原因,我们可能想检查作业类型和作业编号是否已知。我们可能要做的另一件事是修改 raw 属性和 contrib 属性,它们的值依赖于来自配置文件的数据。同样的情况也适用于 adjusted-score 属性,其值取决于 raw 属性、contrib 属性和 score 属性。

Raku 提供了一种通过 TWEAK 子方法检查对象构造后的内容或修改属性的简单方法。简单地说,子方法是不被子类继承的方法。查看文档以获得关于 TWEAK 子方法的更多信息。 让我们在 Assignment 类中添加 TWEAK 子方法:

class Assignment {
    # same attributes as before.
 
    # use submethod keyword, instead of method.
    submethod TWEAK() {
        # assignment type is either 'Homework' or 'Exam'.
        unless $!type eq 'Homework' | 'Exam' {
            die "unknown assignment type: $!type";
        }

        # check if provided assignment type is known.
        unless %!config{$!type}[$!number] {
            die "unrecognized $!type number: $!number";
        }

        # update raw and contrib value from configuration data.
        ($!raw, $!contrib) = %!config{$!type}[$!number];

        # calculate the value of the adjusted score (rounded to two 
        # decimal places).
        $!adjusted-score = sprintf "%.2f", $!score / ($!raw/$!contrib);

        # update type with assignment number. This will be useful 
        # when printing the report for a specific assignment.
        $!type = $!type eq 'Homework'
            ?? "Homework $!number"
            !! "Exam $!number";
    }
    
}

完成 ASSIGNMENT 和 STUDENT 类

ASSIGNMENT 类

我们想为特定的作业打印一个报告,因此我们将添加一个 formatted-score 方法,它将返回调整后的分数,并将一个 print-report 方法返回给 Assignment 类。print-report 方法应打印以下格式的作业报告:

type number: Raw = score/raw : Adjusted = adjusted-score/contrib-final

例子:

Homework 1: Raw = 42/50 : Adjusted = 8.40/10
Exam 1: Raw = 70/75 : Adjusted = 8.40/10
class Assignment {
    # same code as before
   
    method formatted-score {
        return $!adjusted-score;
    }

    method print-report {
        print "$!type: raw = $!score/$!raw : ";
        say "Adjusted = $!adjusted-score/contrib";
    }
  
}

STUDENT 类

现在,我们将添加 get-home-worksget-exams 方法,这些方法将返回 Assignment 对象列表。我们还将为打印学生报告添加 print-report 方法。此方法应以下列格式打印学生报告:

student:
    type number: Raw = score/raw : Adjusted = adjusted-score/100 
    ...
    Final Course Grade: final-total/100
class Student {
    # same code as before
   
    # we use the grep() function to discard possibly empty 
    # elements in either array.
    
    method get-homeworks {
        return %!assignments<Homework>[1..*].grep: { $_ };
    }

    method get-exams {
        return %!assignments<Exam>[1..*].grep: { $_ };
    }

    method print-report {
        say $!name, ": ";

        # print message and return if student's doesn't have
        # neither assignment type
        unless self.get-homeworks() || self.get-exams() {
            say "\tNo records for this student.";
            return;
        }

        my ($final-total, $a_count, $e_count) = (0, 0, 0);

        # Loop over student's assignments (either Homework or Exam),
        # print assignment's report and update final total.
        for self.get-homeworks() -> $homework {
            print "\t";
            $homework.print-report();
            $final-total += $homework.formatted-score();
            $a_count++;
        }

        for self.get-exams() -> $exam {
            print "\t";
            $exam.print-report();
            $final-total += $exam.formatted-score();
            $e_count++;
        }

        # check if number of homeworks and exams in config file
        # matches student's record of returned homeworks and taken exams.
        if (%!config<Homework>[0]<total> == $a_count and
            %!config<Exam>[0]<total>   == $e_count
        ) {
            say "\tFinal Course Grade: $final-total/100";
        }
        else {
            say "\t* Incomplete Record *";
        }

        # print newline after student's report.
        "".say;
    }
    
}

Course 类

Course 类有以下属性:

course——表示课程名称的字符串。 students——学生和 Students 对象的哈希。 number——这个课程的学生人数。

这是带有其属性的类:

class Course {
    has Str $.course;
    has Int $.number;
    has %!students of Student; # specifying the type of the hash's values.
}

关于方法,我们需要以下几点:

  • configure-course——使用当前目录中的 .cfg(配置)和 .std (学生列表)文件来配置课程。
  • student——接受一个学生的名字,并返回一个 Student 对象,前提是它存在。在该类的内部使用所以定义为私有的。
  • get-roster——返回学生名字的排序列表。
  • add-student-record——接受一个学生记录(例如,Bill Jones:1:45),为那个学生查找 Student 对象并添加一个作业。
  • print-report——打印整个类的报告。
class Course {
    # same code as before
    
    method configure-course {
        # read content of configuration file. We are to assume that 
        # it has the same name as the course with '.cfg' extension.
        my $course_file = $!course ~ '.cfg';
        my $course_data = $course_file.IO.slurp || 
                          die "cannot open $course_file";
        
        # extract the data from file and store it into the 
        # configuration hash. The structure of the configuration
        # file was discussed in the 'The Assignment class' section.
        
        my %cfg;
        for $course_data.lines -> $datum {
            my ($type, @data) = $datum.split(':');
            # Example: type = 'Homework', data = (1, 50, 25)
            
            %cfg{$type}[ @data[0] ] = @data[1..*];
            %cfg{$type}[0]<total>++;
        }
        
        # read student list file which has the same name as the course.
        my $stud_file = $!course ~ '.std';
        my $stud_data = $stud_file.IO.slurp || die "cannot open $stud_file";
       
        # Loop over the student list and create a Student object for 
        # each student and populate the hash of students and Student objects.
        
        for $stud_data.lines -> $student {
            %!students{ $student.trim } = Student.new( 
                name   => $student.trim,
                config => %cfg,
            );
            $!number++;
        }

    }
    
    # return Student object if it exists.
    method !student( Str $stud-name ) {
        return %!students{$stud-name} || Nil;
    }
    
    # order student names by last name and return list.
    method get-roster {
        %!students.keys                         # student names list
            ==> map ({ ( $_, $_.words ).flat }) # (full name, first, last)
            ==> sort ({ $^a[2] cmp $^b[2] })    # sort list by last names
            ==> map ({ $_[0] })                 # get name from sorted list
            ==> my @list;

        return @list;
    }

    # add record to Student object.
    method add-student-record( @record ) {
        my ($name, @remaining) = @record;
    
        # get Student object and add assignment to it.
        my $student = self.student($name);
        my ($num, $score) = @remaining;
        $student.add-assignment($num, $score);
    }

    # print report for all students in the course.
    method print-report {
        say "Class report: course = $!course, students = $!number";
        
        # loop over sorted students list and print each student's report.
        for self.get-roster() -> $name {
            self!student($name).print-report();
        }
    }

}

自定义构造函数

我们希望使用反映所创建内容的构造函数方法,而不是使用 new 构造函数从特定的类创建对象。例如,Course 类的 create-course 构造函数。我们还希望使用位置参数,而不是在 new 构造函数中使用命名参数。在 Raku 中创建构造函数相当容易;只需要创建一个方法并返回 blessed 后的参数:

class Course {
    # same code as before

    method create-course( $course ) {
        return self.bless(
            course => $course,
        );
    }

    # same code as before
}

# In addition to:
my $class01 = Course.new( course => 'GEO-102' );

# We can also create a Course instance like this now:
my $class02 = Course.create-course( 'Math-101' );

我们不只是写 return self.bless($course),因为 bless 方法需要一组命名参数来为每个属性提供初始值。正如前面提到的,私有属性实际上是私有的,因此对于用! twigil 定义的属性这还不够。为此,必须使用 BUILD 子方法,bless 方法调用这个全新的对象。有关 BUILD 子方法的更多信息,请参阅子方法构造函数

实例和类属性

我们已经讨论了实例属性,只是没有将它们标识为实例属性。实例属性是一个类的特定实例所拥有的属性,这意味着同一个类的两个不同的对象实例是不同的。例如,前几节中的实例 $class01$class02 都具有相同的实例属性(coursenumber 等),但是值不同。在 Raku 中,任何用关键字 has 声明的属性都是一个实例属性。

另一方面,类属性是属于类本身而不是它的对象的属性。与实例属性不同,类属性由类的所有实例共享。

在 Raku 中,类属性是使用关键字 myour(而不是 has)来声明的,这取决于作用域(例如,my $class-var;)。与实例属性类似,用 . twigil 生成一个访问器方法(例如,my $.class-var;)的类属性。有关类属性的更多信息,请参阅文档

我们还没有看到类属性,但是我们现在要创建一个。例如,我们想知道我们实例化的课程的数量。为此,我们可以在 Course 类中创建一个类属性,它跟踪实例化的 Course 对象的数量。

每当创建新对象时这个类属性必须更新,因此我们必须修改默认的 newcreate-course 构造函数:

class Course { 
    # other attributes.
    
    my Int $.course-count = 0; # class attribute with read-only accessor

    method create-course( $course ) {
        $.course-count++;      # updating the class attribute.
        return self.bless(
            course => $course,
        );
    }

    # we still want to pass named parameters to the new 
    # hence the ':' before the parameter.
    method new( :$course ) {
        $.course-count++;
        return self.bless(
            course => $course,
        );
    }

    # same code as before
}

for 1..5 {
    Course.create-course('PHYS-110');
    Course.new(course => 'BIO-112');
}

# accessing the class attribute's value by calling 
# its accessor method on the class itself.
say Course.course-count();  # OUTPUT: 10

实例方法和类方法

实例方法需要在其上调用对象实例。它通常以 $object.instance-method() 的形式表示。例如,print-report()Course 类的一个实例方法,需要调用该类的一个实例(例如,$class.print-report())。

另一方面,类方法作为一个整体属于类,因此它不需要类的实例。它通常用 class.class-method() 表示。例如,new 构造函数是直接在类上调用的类方法,而不是类的实例。

前面我们提到,显式调用者可以传递给方法。除了显式地引用对象外,方法签名中提供的调用者还允许通过使用类型约束将方法定义为实例方法或类方法。特殊变量 ::?CLASS 可用于在编译时提供类名,并结合 :U (如 ::?CLASS:U)用于类方法,或 :D(如 ::?CLASS:D)用于实例方法。顺便说一下,在 Raku 行话中,:U:D 被称为 smileys

create-course 方法打算仅作为类方法使用,但是,到目前为止,没有任何东西阻止它在实例中使用。为了避免这种情况,我们可以在 invocant 上使用带有 :U 类型修饰符的特殊变量 ?::CLASS,这会导致方法主动拒绝对实例的调用,并且只允许通过type 对象进行调用。

class Course {
    # other attributes 
 
    method create-course( ::?CLASS:U: $course ) {
        $.course-count++;
        return self.bless(
            course => $course,
        );
    }

    method new( ::?CLASS:U: :$course ) {
        $.course-count++;
        return self.bless(
            course => $course,
        );
    }

    # same code as before
}

# Invocations on the Course class works as expected.
my $math = Course.new(name => 'MATH-302');
my $phys = Course.create-course('LING-202');


# Invocations on Course instances fail.
$math.new(name => 'MATH-302');   # OUTPUT: Invocant of method 'new' must be 
                                 # a type object of type 'Course', not an 
                                 # object instance of type 'Course'.  Did you
                                 # forget a 'multi'?

$phys.create-course('LING-202'); # OUTPUT: Type check failed in binding to 
                                 # parameter '$course'; expected Course but 
                                 # got Str ("Phys-302")

使用 COURSE 类

假设我们有 MATH-101.cfg(课程设置),MATH-101.std(学生名单)和 MATH-101(学生记录)有了问题陈述中提供的信息,我们可以使用 Course 类(与 StudentAssignment 在同一个文件中),如下:

# For now, we'll specify the course name manually.
my $class = Course.new( course => 'MATH-101' );
    
# set up the course. Remember that the script assumes 
# 'MATH-101.cfg' and 'MATH-101.std' are in the current directory.
$class.configure-course();

# filename of student record. 
my $data = 'MATH-101';

# loop over each line of student record and feed it to
# the corresponding student.
for $data.IO.lines -> $line {
    $class.add-student-record( $line.split(':') );
}

# print course report.
$class.print-report();

运行该程序之后,它打印出:

Class report: course = MATH-101, students = 4
Frank Horza: 
	No records for this student.
Bill Jones: 
	Homework 1: Raw = 35/50 : Adjusted = 17.50/25
	Homework 2: Raw = 42/50 : Adjusted = 21.00/25
	Exam 1: Raw = 72/75 : Adjusted = 48.00/50
	Final Course Grade: 86.5/100

Anne Smith: 
	No records for this student.
Sara Tims: 
	Homework 1: Raw = 39/50 : Adjusted = 19.50/25
	Homework 2: Raw = 45/50 : Adjusted = 22.50/25
	* Incomplete Record *

完成程序

您可能注意到,在创建 Course 实例之后,我们必须调用 configure-course。每次创建一个 Course 实例后都要这样做,这样我们就可以添加一个 TWEAK 方法来执行这个任务:

class Course {
    # same code as before

    submethod TWEAK($course:) {
    	# set up course after object creation
        $course.configure-course();
    }
    
    # same code as before
}

问题陈述说明程序应该通过命令行接收学生的成绩。Raku 使命令行参数解析非常容易,我们只需要定义一个 MAIN 子例程来获取一个位置参数,即以包含学生成绩的课程命名的文件。要了解关于 MAIN 子例程的更多信息,请阅读本文参考文档

让我们创建一个 report.p6 文件,其中也存储了 Assignment, StudentCourse 类:

use v6;

# assume the Assignment, Student, and Course class are here.

sub MAIN( $course ) {
    # CLI arguments are stored in @*ARGS. We take the first one. 
    my $class = Course.create-course( @*ARGS[0] );

    for $course.IO.lines -> $line {
        $class.add-student-record( $line.split(':') );
    }
 
    # printing the class report.
    $class.print-report();
}

假设这样:

$ ls
MATH-101  MATH-101.cfg  MATH-101.std  report.p6

然后:

$ raku report.p6 MATH-101

应该打印出报告。

继承

我们已经完成了问题陈述所提出的任务,但是我们将在这里讨论的主题在OOP范例中非常重要。Student 类有一个属性,用于存储学生已经注册的课程。现在,让我们假设我们想为兼职学生创建一个 PTStudent 类,它限制了一个学生可以注册的课程数量。考虑到兼职学生肯定是学生,我们可能会被诱使将 Student 类中的代码复制到 PTStudent 中,然后添加必要的约束。虽然技术上很好,但是代码的重复被认为是次优的、容易出错的和概念上有缺陷的工作。相反,我们可以使用一种称为继承的机制。

简单地说,继承允许从现有类派生一个新类(带有修改)。这意味着您不必创建完整的新类来复制现有类的部分。在这个过程中,继承的类是父类孩子(或子类)。

在 Raku 中,is 关键字定义了继承。

# Assume Student is defined here.

class PTStudent is Student {
    # new attributes particular to PTStudent.
    has @.courses;
    my $.course-limit = 3;  
    
    # new method too.
    method add-course( $course ) {
        @.courses == $.course-limit {
            die "Number of courses exceeds limit of $.course-limit.";
        }
        
        push @.courses, $course;
    }
}

my $student2 = PTStudent.new(
    name => "Tim Polaz",
    config => %(),
);

$student2.add-course( 'BIO-101' );
$student2.add-course( 'GEO-101' );
$student2.add-course( 'PHY-102' );

$student2.courses.join(' ');       # OUTPUT: 'BIO-101 GEO-101 PHY-102'
        
$student2.add-course( 'ENG-220' ); # (error) OUTPUT: Number of courses exceeds 
                                   # limit of 3.

PTStudent 类继承了其父类 Student 的属性和方法。除此之外,我们还为它添加了两个新属性和一个新方法。

如果我们愿意,我们可以重新定义从 Student 类继承的方法。这个概念被称为覆盖,它允许为父类和子类提供每个类的通用方法实现。

除了单继承之外,Raku 中还可以有多重继承(一次从多个类继承)。查看文档以获得关于它的更多信息。

角色

与类类似,角色携带状态和行为。然而,与类不同,角色是用来描述对象行为的特定组件的。在 Raku 中,角色是用 role 关键字定义的,并使用 does 特质(与用于继承的 is 相反)应用于类或对象。

# Assume Student is defined here

role course-limitation {
	has @.courses;
    my Int $.course-limit = 3;
   
    method add-course( $course ) {
        if @.courses == $.course-limit {
            die "Number of courses exceeds limit of $.course-limit.";
        }
        @.courses.push($course);
    }
}

# inheriting from the Student class and applying role.
class PTStudent is Student does course-limitation { }

my $student2 = PTStudent.new(
    name => "Tim Polaz",
    config => %()
);

$student2.add-course( 'MATH-101' );
$student2.add-course( 'GEO-101' );
$student2.add-course( 'PHY-102' );

say $student2.courses.join(' ');   # OUTPUT: 'MATH-101 GEO-101 PHY-102'
        
$student2.add-course( 'ENG-220' ); # (error) OUTPUT: Number of courses exceeds 
                                   # limit of 3.

我们已经演示了角色是如何工作的,但这并没有展示它们的完整画面。例如,就像多重继承一样,角色可以在一个类中多次实现(例如,class Name does role1 does role2 does ...)。但是,与多重继承不同的是,如果多个角色的应用程序出现冲突,则会抛出编译时错误。对于多重继承,冲突不会被认为是错误,而是会在运行时解决。

简而言之,角色可以被认为是继承的另一种选择; 程序员不是通过子类化来扩展类层次结构,而是使用为类的行为提供补充行为的角色来组成类。

内省

内省 是一个对象能够收集关于自身和其他对象的信息的过程,如类型、方法、属性等。

在 Raku 中,内省可以通过以下结构得到促进:

  • .WHAT——返回与对象关联的类型对象。
  • .perl——返回一个字符串,该字符串可以作为 Raku 代码执行。
  • .^name——返回类名。
  • ^attributes——返回对象的所有属性。
  • ^methods——返回可以在该对象上调用的所有方法。
  • ^parents——返回对象的父类。
  • ~~ 是智能匹配操作符。如果对象是由正在进行比较的类或其任何它的继承类创建的,则计算为 True

.^ 语法是元方法调用。使用这种语法而不是单个点表示对其元类的方法调用,元类是管理所有类的属性的类。事实上, obj.^meth 相当于obj.HOW.meth(obj), 其中 meth 是一个特定的方法。

使用上一节中的对象 $student2:

say $student2.WHAT;         # OUTPUT: (PTStudent)
say $student2.perl;         # OUTPUT: PTStudent.new(name => "Tim Polaz", 
                            #         config => {}, courses => [])
say $student2.^name;        # OUTPUT: PTStudent
say $student2.^attributes;  # OUTPUT: (Str $!name Associative %!config Mu 
                            #          $!assig-num Associative %!assignments
                            #          Positional @!courses)
say $student2.^methods;     # OUTPUT: (course-limit add-course name 
                            #          add-assignment courses config get-exams
                            #          print-report get-homeworks BUILDALL)
say $student2.^parents;     # OUTPUT: ((Student))

say $student2 ~~ PTStudent; # True
say $student2 ~~ Student;   # True
say $student2 ~~ Str;       # False

如您所见,通过内省,您可以从类的实例中学到很多东西。

内省并不仅限于程序员定义的类。内省是 Raku 的核心,它是一个非常有用的工具,可以帮助您了解内置类型,并从整体上了解该语言。

结论

在这篇文章中,我们学习了如何定义类、私有和公共属性以及私有和公共方法。我们还学习了如何创建自定义构造函数,并将其调用限制在类或类的实例中。此外,我们还简要讨论了如何通过继承和 Raku 中的角色来促进代码重用。最后,我们讨论了内省的过程,以及如何以最简单的形式学习对象。

关于 report.p6,我们局限于问题陈述,但是程序的用户可以从额外的功能中获益。例如,可以修改程序,为单个学生提供交互式查询,以便学生从命令行查找。此外,该程序可以读取多个课程,然后查询它们,检索并打印一个特定学生的记录。

下面我链接了我们在这里创建的程序和实现前面提到的额外功能的程序。希望这整个教程是有益的和有用的。

链接:

  • 整个 report.p6
  • report.p6 有额外的功能。我还添加了一个简单的函数来给课程名称上色,以便更好地将它们与其他文本区分开来。对于一些课程文件,输出结果如下:

img

资源

  • Raku Introduction
  • Raku Documentation
    • Classes and objects
    • Object orientation
  • Raku Advent Calendar
    • The humble type object
    • Classes, attributes, methods and more
    • Introspection
  • Basic OO in Raku (slides)
  • Let’s build an object
  • Think Raku
  • The problem statement was an adaptation from a section titled Grades: an object example in the book Elements of Programming with Perl by Andrew L. Johnson.