8. 方法

本章是领域建模的最后一个章节,主要介绍了方法相关的内容,方法可以定义在类、样例类、特质、枚举和对象中。值得注意的是,Scala 3中有一个重大的变化是方法也能定义在这些结构之外。因此,一个完整的Scala 3应用程序如下所示:

    def printHello(name: String) = println(s"Hello, $name")
    def printString(s: String) = println(s)

    @main def hiMom =
        printHello("mom")
        printString("Look mom, no classes or objects required!")

Scala中的方法与其他编程语言中的方法类似。在Scala中,方法使用def关键字定义,通常携带一个或多个参数,然后执行算法,并返回某种结果。比如,一个最基本的不包含泛型以及using参数的方法定义如下:

    def methodName(paramName1: type1, paramName2: type2, ...): ReturnType =
        // the method body
        // goes here

声明方法的返回类型是可选的,但我发现,对于维护一个几个月或几年没有看过的项目时,如果花一些时间来梳理或声明类型,可以在几天、几个月、甚至几年后回过头来查看时,也能更容易地理解其中的基本情况。当方法没有添加类型或使用其他动态类型语言时,我必须在将来花费相当长的时间来查看方法体,以确定其返回类型是什么,并且方法体越长,所需的时间就越长。因此,大多数开发者都认为最好对方法的返回类型进行声明。正如我在“Scala/FP: Pure Function Signatures Tell All”( https://oreil.ly/oaXZg )文中所写,当进行函数式编程时,你就会发现纯函数签名非常有意义。

def关键字的前面还可以使用一些其他关键字。比如,如果不希望方法被子类继承重写,可以被声明为final类型:

    class Foo:
        final def foo = "foo"        // FINAL

    class FooFoo extends Foo:
        override def foo = "foo foo" // ERROR, won’t compile

其他像protectedprivate等关键字,本章也会进行演示,以说明如何控制方法的作用范围。

Scala被认为是一种面向表达式的编程语言,这意味着每一行代码都是一个表达式:它返回一个值,且通常没有副作用。因此,方法可以非常简洁,像iffor/yieldmatchtry这样的结构都是含有返回值的表达式。简洁易读的代码被称为表达式(expressive),为了表现这一点,我将展示一些使用这些结构的表达式方法。

首先,下面展示了一些只使用等值测试或if表达式作为方法体的例子:

    // with return type
    def isBetween(a: Int, x: Int, y: Int): Boolean = a >= x && a <= y
    def max(a: Int, b: Int): Int = if a > b then a else b

    // without return type
    def isBetween(a: Int, x: Int, y: Int) = a >= x && a <= y
    def max(a: Int, b: Int) = if a > b then a else b

如果想要更容易阅读,也可以将方法主体放在单独的一行上:

    def isBetween(a: Int, x: Int, y: Int): Boolean =
        a >= x && a <= y
    def max(a: Int, b: Int): Int =
        if a > b then a else b

然后,下面展示了使用match表达式作为方法体的例子:

    def sum(xs: List[Int]): Int = xs match
        case Nil => 0
        case x :: tail => x + sum(tail)

最后,对于for表达式,结合for/yield也可以作为方法体:

    def allThoseBetween3and10(xs: List[Int]): List[Int] =
        for
            x <- xs
            if x >= 3 
            if x <= 10
        yield
            x

    println(allThoseBetween3and10(List(1,3,7,11))) // List(3, 7)

类似的,也可以在很多其他结构上使用这种技术,比如try/catch表达式。

上面介绍的例子展示了Scala方法的几个特性,但远不止如此。接下来的小节将进行详细地介绍:

  • 指定方法的访问控制,即方法的可见性(8.1小节)

  • 调用父类或者特质中的方法(8.2小节)

  • 调用方法时指定参数名传参(8.3小节)

  • 给方法参数定义默认值(8.4小节)

  • 使用可变参数(8.5小节)

  • 强制调用方不使用括号调用方法(8.6小节)

  • 声明方法可能抛出的异常(8.7小节)

  • 使用特殊的技术支持方法链式调用的编码风格(8.8小节)

  • 使用Scala 3新引入的扩展方法语法(8.9小节)

最后,除了方法的定义,在Scala中也可以使用val关键词进行函数(function)的定义。函数将在第10章进行详细讨论,并且我在“Scala: The Differences Between val and def When Creating Functions”( https://oreil.ly/7yTLg )一文中详细描述了这些差异。

8.1 控制方法作用域(访问修饰符)

问题

Scala中的方法缺省的访问修饰符是public,而你希望能够像Java那样控制方法的作用域。

解决方案

Scala允许更细粒度地控制方法的可见性。按照“最严格”到“最开放”的顺序,Scala提供以下的作用域级别:

  • 私有作用域(Private)

  • 保护作用域(Protected)

  • 包内可见的作用域(Package)

  • 指定包内可见的作用域(Package Specific)

  • 公开的作用域(Public)

下面的例子分别展示了这些作用域的级别。


private[this] 和 protected[this] -- TODO(鸽子图)

Scala 2提供了 private[this]protected[this] 范围限定符的概念,但这些已经被废弃了。详情参阅Scala 3页面( https://oreil.ly/aRAfJ )中的有关讨论。


私有作用域

最严格的访问是将方法设置为私有(private),一个私有方法对(a)一个类中的当前实例和(b)该类的其他实例可见。下面的例子展示了如何将一个方法变成私有,并且如何被用一个类的其他实例调用:

    class Cat:
        private def isFriendlyCat = true
        def sampleMethod(other: Cat) =
            if other.isFriendlyCat then
                println("Can access other.isFriendlyCat")
                // ...
            end if
        end sampleMethod
    end Cat

类的私有方法对于其子类是不可见的。下面的代码会编译失败,因为heartBeatAnimal类的私有方法:

    class Animal:
        private def heartBeat() = println("Animal heart is beating")

    class Dog extends Animal:
        heartBeat() // ERROR: Not found: heartBeat

想要使方法可以被Dog类访问,可以使用保护作用域。

保护作用域

标记为protected的方法可以(a)对相同类的其他实例可见,(b)在当前包中不可见,(c)对子类可见。下面的例子展示了这些要点:

    class Cat:
        protected def isFriendlyCat = true
        def catFoo(otherCat: Cat) =
            if otherCat.isFriendlyCat then // this compiles
                println("Can access 'otherCat.isFriendlyCat'") 
                // ...
            end if

    // CatHouse
    @main def catTests =
        val c1 = Cat()
        val c2 = Cat()
        c1.catFoo(c2) // this works

        // this code can’t access this method:
        // c1.isFriendlyCat // does not compile

在上面的代码中:

  • catFoo方法中的 if otherCat.isFriendlyCat 表达式展示了在一个Cat类实例中,Cat的其他实例也能访问isFriendlyCat方法。

  • c1.catFoo(c2) 表达式展示了一个Cat实例可以在另一个Cat实例上调用catFoo方法,并且catFoo方法内也可以调用另一个实例的isFriendlyCat方法。

  • 注释掉的代码 c1.isFriendlyCat 展示了一个Cat实例不能直接调用isFriendlyCat方法;protected方法不允许这样做,尽管CatHouseCat在同一个包中。

由于protected可以被子类访问,所以下面的代码可以编译:

    class Animal:
        protected def heartBeat() = println("Animal heart is beating")

    class Dog extends Animal:
        heartBeat() // this

包作用域

为了使一个方法对包内所有成员可见,可以使用 private[packageName] 将方法标记为对当前包私有。

下面的例子中,方法privateModelMethod对同一个包下的其他类可见(model包),但方法 privateMethodprotectedMethod 不可见:

    package com.devdaily.coolapp.model:
        class Foo:
            // this is in “package scope”
            private[model] def privateModelMethod = ??? // can be accessed by
                                                        // classes in
                                                        // com.devdaily.coolapp.model

            private def privateMethod = ???
            protected def protectedMethod = ??? 

    class Bar:
        val f = Foo()
        f.privateModelMethod // compiles
        // f.privateMethod   // won’t compile
        // f.protectedMethod // won’t compile

指定包作用域

除了控制方法对当前包下的类可见之外,Scala还提供了更多的控制级别,可以控制一个方法对不同包级别下的类的可见性。下面的例子展示了方法doUnderModeldoUnderCoolapp、和doUnderAcme对不同包级别的可见性:

    package com.devdaily.coolapp.model:
        class Foo:
            // available under com.devdaily.coolapp.model
            private[model] def doUnderModel = ???

            // available under com.devdaily.coolapp
            private[coolapp] def doUnderCoolapp = ???

            // available under com.devdaily
            private[devdaily] def doUnderAcme = ???

    import com.devdaily.coolapp.model.Foo

    package com.devdaily.coolapp.view:
        class Bar:
            val f = Foo()
            // f.doUnderModel // won’t compile
            f.doUnderCoolapp
            f.doUnderAcme

    package com.devdaily.common:
        class Bar:
            val f = Foo()
            // f.doUnderModel // won’t compile
            // f.doUnderCoolapp // won’t compile
            f.doUnderAcme

例子中出现的方法可以这样理解:

  • doUnderModel 方法对model包(com.devdaily.coolapp.model)下的其他类是可见的。

  • doUnderCoolapp 方法对coolapp包(com.devdaily.coolapp)下的其他类是可见的。

  • doUnderAcme 方法对所有com.devdaily包下的类是可见的。

公开作用域

如果方法声明上没有访问修饰符,方法就是公开级别的,表示任何包中的代码都可以访问这个方法。在下面的例子中,任何包下的任何类都可以访问doPublic方法:

    package com.devdaily.coolapp.model:
        class Foo:
            def doPublic = ???

    package some.other.scope:
        class Bar:
            val f = com.devdaily.coolapp.model.Foo()
            f.doPublic

讨论

Scala的访问控制机制和Java是不同的。Scala的方法默认是公开的,当需要灵活地提供访问控制时,Scala也有对应的解决方案。

作为总结,表8-1描述了Scala中各种级别的访问控制。

表8-1:Scala可见性控制符

修饰符
描述

private

对类中的当前实例和同一个类的其他实例可见。

protected

对当前类及其子类的实例可见。

private[model]

com.devdaily.coolapp.model包下的所有类可见。

private[coolapp]

com.devdaily.coolapp包下的所有类可见。

private[devdaily]

com.devdaily包下的所有类可见。

(无修饰符)

公开方法。

8.2 调用父类或特质的方法

问题

为了减少重复代码,你希望调用一个在父类或者特质中的方法。

解决方案

需要考虑几种可能出现的情况:

  • 一个类中的方法与父类方法的名称不同,并且希望调用该父类的方法。

  • 一个类中的方法与父类方法的名称相同,并且希望调用该父类的方法。

  • 一个类中的方法与多个特质中方法的名称相同,并且希望调用指定特质的方法。

这些问题的解决方案如下所示。

walkThenRun调用walk和run方法

当想要调用父类中的方法,并且方法名与父类的方法不同,可以不用super关键字直接调用:

    class AnimalWithLegs:
        def walk() = println("I’m walking")
        def run() = println("I’m running")

    class Dog extends AnimalWithLegs:
        def walkThenRun() =
            walk()
            run()

在该例子中,Dog中的walkThenRun方法调用父类AnimalWithLegs中的walkrun方法。由于方法名不一样,可以不使用super引用父类。这也是面向对象编程中常规的方法继承。

虽然在这个例子中展示的是父类继承,但如果AnimalWithLegs是一个特质,那么使用也是类似的。

walk方法调用super.walk方法

当一个类中的方法与父类的方法名称相同,并且想要调用父类的方法时,可以使用override定义重写该方法,然后使用super关键字调用父类方法:

    class AnimalWithLegs:
        // the superclass 'walk' method.
        def walk() = println("Animal is walking")

    class Dog extends AnimalWithLegs:
        // the subclass 'walk' method. 
        override def walk() =
            super.walk() // invoke the superclass method.
            println("Dog is walking") // add your own body.

在该例子中,Dogwalk方法与父类的名称一样,所以需要使用super.walk来调用父类的walk方法。

然后创建一个Dog的实例,接着调用walk方法,可以得到如下输出:

    val d = Dog()
    d.walk() 

    // 输出
    Animal is walking
    Dog is walking

在这种情况,如果不希望调用父类的方法,可以简单进行重写,然后定义自己的方法实现:

    class Dog extends AnimalWithLegs:
        override def walk() =
            println("Dog is walking")

此时,创建一个Dog的实例,接着调用walk方法,可以得到如下输出:

    Dog is walking

跟之前的例子一样,如果AnimalWithLegs是特质,那么使用方式也类似的。

控制调用方法所属的特质

如果类继承了多个特质,并且这些特质实现了同样的方法,在使用super调用父类方法时,不仅要选择调用的方法,还可以选择要调用的特质。比如下面的类继承结构:

    trait Human:
        def yo = "Human"

    trait Mother extends Human:
        override def yo = "Mother"

    trait Father extends Human:
        override def yo = "Father"

下面的例子展示Child类中调用不同特质的yo方法:

    class Child extends Human, Mother, Father:
        def printSuper = super.yo
        def printMother = super[Mother].yo
        def printFather = super[Father].yo
        def printHuman = super[Human].yo

当创建一个Child的实例,然后调用其中的方法,可以得到如下输出:

    val c = Child()
    println(c.printSuper)  // Father
    println(c.printMother) // Mother
    println(c.printFather) // Father
    println(c.printHuman)  // Human

如上所示,当一个类继承了多个特质,这些特质都有一个同名的方法,可以通过super[traitName].methodName来指定调用哪个特质上的方法。注意 c.printSuper 输出的是 Father,因为特质的初始化是从左向右的,而Father是最后一个混入Child的特质:

    class Child extends Human, Mother, Father:
                                       ------

当使用这种方法时,目标特质必须被当前类通过extends关键字直接继承,否则无法访问对应特质的方法。例如,下面的代码会编译失败,是因为Child类没有直接继承Human特质:

    class Child extends Mother, Father:     // removed `Human`
        def printSuper = super.yo
        def printMother = super[Mother].yo
        def printFather = super[Father].yo
        def printHuman = super[Human].yo    // won’t compile

当尝试编译此代码时,会得到“Human does not name a parent of class Child”的错误。

8.3 使用方法参数名

问题

你更喜欢在调用方法时指定方法参数名称的编码风格。

解决方案

调用方法时使用命名参数的语法如下:

    methodName(param1=value1, param2=value2, ...)

下面使用一个例子进行展示,比如给定一个Pizza类的定义:

    enum CrustSize:
        case Small, Medium, Large

    enum CrustType:
        case Regular, Thin, Thick

    import CrustSize.*, CrustType.*

    class Pizza:
        var crustSize = Medium
        var crustType = Regular
        def update(crustSize: CrustSize, crustType: CrustType) =
            this.crustSize = crustSize
            this.crustType = crustType
        override def toString = s"A $crustSize inch, $crustType crust pizza."

首先创建一个Pizza实例:

    val p = Pizza()

然后可以调用update方法更新这个Pizza,调用update方法的时候可以指定参数名和其相应的值:

    p.update(crustSize = Large, crustType = Thick)

这种方式的好处是可以不用关心传入参数的顺序:

    p.update(crustType = Thick, crustSize = Large)

虽然这比不使用命名参数的方式更啰嗦,但更具可读性。

讨论

这种方式在多个参数有相同类型时非常有用,比如BooleanString类型的参数。例如,这个方法调用:

    engage(true, true, true, false)

和这个方法调用:

    engage(
        speedIsSet = true,
        directionIsSet = true,
        picardSaidMakeItSo = true,
        turnedOffParkingBrake = false
    )

哪个更可读一些呢?

最后,当一个方法提供了默认参数值的时候,像接下来8.4小节那样,可以使用这种方式只给某个参数赋值。这两种方式(命名参数和参数默认值)的结合使用将更灵活强大。

8.4 设置方法参数默认值

问题

你想给方法的参数设置默认值,以便在调用此方法时可以不传入参数。

解决方案

在方法签名中指定参数的默认值,语法如下:

    parameterName: parameterType = defaultValue

在下面的代码示例中,timeout参数的默认值是5000protocol参数的默认值是“http”:

    class Connection:
        def makeConnection(timeout: Int = 5_000, protocol: String = "https") =
            println(f"timeout = ${timeout}%d, protocol = ${protocol}%s")
            // more code here

当创建一个Connection的实例c,可以通过如下方式调用这个方法(每行的注释表示输出):

    val c = Connection()
    c.makeConnection()              // timeout = 5000, protocol = https
    c.makeConnection(2_000)         // timeout = 2000, protocol = https
    c.makeConnection(3_000, "http") // timeout = 3000, protocol = http

如果调用方法时喜欢把参数名带上,也可以像8.3小节那样使用:

    c.makeConnection(timeout=10_000)
    c.makeConnection(protocol="http")
    c.makeConnection(timeout=10_000, protocol="http")
    c.makeConnection(protocol="http", timeout=10_000)

如上所示,结合这两种方式可以在一些特定的场景下创建可读性更高的代码。

讨论

和构造参数一样,可以为方法的参数设置默认值。因此,方法的调用者可以(a)传入一个新的值以覆盖掉默认值,也可以(b)跳过这个参数,使用其默认值。

参数的赋值顺序是从左到右的,因此下面不设置任何参数的调用会使用timeoutprotocol的默认值:

    c.makeConnection()

下面的调用把timeout设为2000protocol则使用默认值:

    c.makeConnection(2_000)

下面的调用timeoutprotocol都进行了赋值:

    c.makeConnection(3_000, "ftp")

注意通过上述的方式无法只设置protocol的值,代码将无法编译。但是,可以使用命名参数:

    c.makeConnection(protocol="http")

如果给一个方法的某些参数设置默认值,而另外一些则不设置,且将设置默认值的参数靠后排列。下面的例子将展示正确的使用方式:

    class Connection:
        // correct implementation, default value is listed last
        def makeConnection(timeout: Int, protocol: String = "https") =
            println(f"timeout = ${timeout}%d, protocol = ${protocol}%s")

    val c = Connection()
    c.makeConnection(1_000)         // timeout = 1000, protocol = https
    c.makeConnection(1_000, "http") // timeout = 1000, protocol = http

相反,将设置默认值的参数靠前排列,下面的例子将展示这个问题:

    class Connection:
        // intentional error
        def makeConnection(timeout: Int = 5_000, protocol: String) =
            println(f"timeout = ${timeout}%d, protocol = ${protocol}%s")

这段代码编译没有问题,但是当创建Connection实例时,无法利用参数默认值的优势,像这样:

    val c = Connection()
    c.makeConnection(1_000, "http")   // timeout = 1000, protocol = http
    c.makeConnection(2_000)           // compiler error
    c.makeConnection("https")         // compiler error

    // but this still works
    c.makeConnection(protocol = "http") // timeout = 5000, protocol = http

8.5 创建可变参数的方法

问题

为了让方法更加灵活,你希望定义的方法可以接受可变数量的参数,比如varargs字段。

解决方案

在参数类型后面加一个 * ,这个参数就变成了可变参数:

    def printAll(strings: String*) =
        strings.foreach(println)

然后,调用printAll方法可以传入0个或者多个参数:

    // these all work
    printAll()
    printAll("a")
    printAll("a", "b")
    printAll("a", "b", "c")

使用 _* 来适配一个序列

在默认情况下,不能将一个序列(List、Seq、Vector等等)传入可变参数的方法,但是可以使用Scala中的 _* 操作符可以对序列进行适配,以解决这个问题:

    val fruits = List("apple", "banana", "cherry")
    printAll(fruits)       // fails (Found: List[String]), Required: String)
    printAll(fruits: _*)   // works

把 _* 想象成 “Splat” -- TODO(鸽子图)

如果有Unix背景,可以把 _* 想象成一个splat或者xargs操作符。这个操作符告诉编译器把序列中的每个元素作为一个单独的参数传给printAll方法,而不是把整个fruits序列作为一个参数。


讨论

当定义一个含有可变参数的方法时,这个可变参数必须是方法签名中的最后一个参数。尝试在可变参数后面在定义一个参数将会引起编译错误:

    // error: this won’t compile
    def printAll(strings: String*, i: Int) =
        strings.foreach(println)

幸运的是,编译错误的信息非常清晰:

    def printAll(strings: String*, i: Int) =
                          ^^^^^^^
                          varargs parameter must come last

根据该规则可以看出,一个方法只能有一个可变参数字段。

8.6 强制调用方不使用括号调用方法

问题

你想要强制一种编码风格,即调用访问器方法(getter)时,不可以使用括号。

解决方案

在定义方法时,去掉方法后面的括号:

    class Pizza:
        // no parentheses after 'crustSize'
        def crustSize = 12

这会强制调用者在访问crustSize时不能使用括号,否则将会导致编译错误:

    scala> val p = Pizza()
    p: Pizza = Pizza@3a3e8692

    // this fails because of the parentheses
    scala> p.crustSize()
    1 |p.crustSize()
      |^^^^^
      |method crustSize in class Pizza does not take parameters

    // this works
    scala> p.crustSize
    res0: Int = 12

讨论

在Scala中调用无副作用的访问器方法时,推荐的做法是去掉括号。在Scala风格指南( https://docs.scala-lang.org/style )中对此有如下描述:

    声明任何类型的访问器方法(封装字段或逻辑属性)都应该不带括号,除非它们有副作用。

因此,像crustSize这种没有任何副作用的简单方法,应该被以无括号的方式进行调用,本小节展示了如何强制使用这种方式。这只是一个约定,如果严格遵守的话是一个不错实践。例如,有一个名为 printStuff 的方法,尽管从名字来看就知道这个方法可能会输出一些东西,但如果调用时是采用 printStuff() 的形式就会引起我的注意,这是一个有副作用的方法。

另见

  • 有关命名约定和括号的Scala风格指南( https://oreil.ly/mx6xl )有更多关于accessors、mutators、以及括号使用相关的介绍。

  • 参阅第10章和24.1小节有关副作用的讨论。

8.7 方法的异常声明

问题

你想给方法增加抛出异常的声明,为了让调用者知晓,也为了可以从Java代码中调用。

解决方案

使用 @throws 注解声明可能抛出的异常。可以像这样声明一个异常:

    @throws(classOf[Exception])
    def play =
        // exception throwing code here ...

也可以像这样声明多个异常:

    @throws(classOf[IOException])
    @throws(classOf[FileNotFoundException])
    def readFile(filename: String) =
        // exception throwing code here ...

讨论

上面两个例子,我给这两个方法增加异常声明是出于两个原因。首先,无论调用者使用的是Scala还是Java,如果他们想要编写健壮性更高的代码时,他们应该知道这些方法可能会抛出的异常。

其次,如果他们使用的是Java,@throws 等同于Java中使用throws关键字的方法签名,例如Java中方法声明异常的语法如下:

    // java
    public void play() throws Exception {
        // code here ...
    }

值的注意的是,Scala中对待检查型异常和Java中是不太一样的。Scala不强制需要方法声明可能抛出的检异型常,也不要求调用者捕捉它们。例如,给定一个方法如下:

    def shortCircuit() = throw Exception("HERE’S AN EXCEPTION!")

不需要声明可能抛出的异常,也不需要使用try/catch表达式进行包裹。但是如果异常发生了,代码也会停止执行:

    scala> shortCircuit()
    java.lang.Exception: HERE’S AN EXCEPTION!
            at rs$line$8$.except(rs$line$8:1)
        much more output ...

Java异常类型

我们来快速回顾一下Java的异常类型,包括(a)检查型异常,(b)Error的子类,以及(c)RuntimeException的子类。和检查型异常一样,ErrorRuntimeException也有很多子类,如著名的NullPointerException

根据Java有关Exception类的文档( https://oreil.ly/XxFFr )中描述,Exception和任何非RuntimeException的子类都是检查型异常。如果一个方法或者构造函数可能抛出检查型异常,这些检查型异常需要被声明在方法或者构造函数的throws子句中。

下面的链接提供了更多关于Java异常和异常处理的信息:

  • Java教程:“三种Java异常”( https://oreil.ly/6Yauz )。

  • Java教程:“关于非检查型异常的争论”( https://oreil.ly/6hCes

  • 维基百科关于检异型常的讨论( https://oreil.ly/M1zFD

  • Java教程:“处理异常的入门教程”( https://oreil.ly/iqAeJ

  • Java Exception类( https://oreil.ly/XxFFr


另见

  • 参阅22.7小节有关方法添加异常注解的更多例子。

  • 参阅10.8小节有关函数式编程中异常处理的细节。

8.8 支持链式调用编码风格

问题

在OOP风格创建类时,你希望设计一个支持流畅(fluent) 编程风格的API,又叫 方法链式(method chaining) 编程。

解决方案

链式编程风格的代码能够把方法的调用链接起来,比如下面的例子:

    person.setFirstName("Frank")
        .setLastName("Jordan")
        .setAge(85)
        .setCity("Manassas")
        .setState("Virginia")

为了支持这种风格的代码,需要:

  • 如果类可能会被扩展,则把this.type作为链式调用风格方法的返回值类型。

  • 如果类不会被扩展,则把this从链式调用方法中返回出来。

下面的代码展示了如何吧this.type作为 set* 方法的返回值类型:

    class Person:
        protected var _firstName = ""
        protected var _lastName = ""

        def setFirstName(firstName: String): this.type = // note `this.type`
            _firstName = firstName
            this

        def setLastName(lastName: String): this.type = // note `this.type`
            _lastName = lastName
            this
    end Person

    class Employee extends Person:
        protected var employeeNumber = 0

        def setEmployeeNumber(num: Int): this.type =
            this.employeeNumber = num
            this

        override def toString = s"$_firstName, $_lastName, $employeeNumber"
    end Employee

下面的例子展示如何把这些方法通过链式调用串连起来:

    val employee = Employee()

    // use the fluent methods
    employee.setFirstName("Maximillion")
            .setLastName("Alexander")
            .setEmployeeNumber(2)

    println(employee) // prints "Maximillion, Alexander, 2"

讨论

如果确定类不会被扩展,就没有必要把 set* 方法的返回值类型指定为this.type;只需要在每个链式方法的最后返回this即可。下面被声明为finalPizza类中的addToppingsetCrustSizesetCrustType方法就是这样做的:

    enum CrustSize:
        case Small, Medium, Large

    enum CrustType:
        case Regular, Thin, Thick

    enum Topping:
        case Cheese, Pepperoni, Mushrooms

    import CrustSize.*, CrustType.*, Topping.*

    final class Pizza:
        import scala.collection.mutable.ArrayBuffer

        private val toppings = ArrayBuffer[Topping]()
        private var crustSize = Medium
        private var crustType = Regular

        def addTopping(topping: Topping) =
            toppings += topping
            this

        def setCrustSize(crustSize: CrustSize) =
            this.crustSize = crustSize
            this 

        def setCrustType(crustType: CrustType) =
            this.crustType = crustType
            this

        def print() =
            println(s"crust size: $crustSize")
            println(s"crust type: $crustType")
            println(s"toppings: $toppings")
    end Pizza

下面的例子展示如何把这些方法通过链式调用串连起来:

    val pizza = Pizza()
    pizza.setCrustSize(Large)
         .setCrustType(Thin)
         .addTopping(Cheese)
         .addTopping(Mushrooms)
         .print()

输出结果如下:

    crust size: Large
    crust type: Thin
    toppings: ArrayBuffer(Cheese, Mushrooms)

类修饰符

根据Scala 3中新关键字open的文档( https://oreil.ly/4lk7Q ),当创建一个类时,“类的继承可能存在三种可能性”:

  • 声明一个含有open的类,表示可以被继承。

  • 声明一个含有final的类,表示禁止被继承。

  • 如果还没有明确的决定,请不要使用修饰语。

在第三种情况,当遇到下面任意一个条件时,类可以被继承:

  • 继承类和原始类处于一个文件中。

  • 为继承类启用Scala语言中adhocExtensions特性,比如在代码中导入scala.language.adhocExtensions

在Scala 3.0中,使用adhocExtensions的特性没有警告提示,但对于Scala 3.1及更高版本,默认情况下会有警告提示。


另见

  • 维基百科关于流畅式接口的定义( https://oreil.ly/Ul5bT

  • Martin Fowler关于流畅式编程的讨论( https://oreil.ly/ukJi7

8.9 向封闭的类中添加扩展方法

问题

你想向一个封闭的类中添加方法,比如向StringInt和任何无法访问源码的类中添加方法。

解决方案

在Scala 3中,可以定义extension方法来实现。例如,想要向String类中添加一个名为hello的方法,然后进行如下的调用:

    println("joe".hello) // prints "Hello, Joe"

为了实现这种行为,可以使用extension关键字将hello定义为扩展方法:

    extension (s: String)
        def hello: String = s"Hello, ${s.capitalize}"

在REPL中的输出如下:

    scala> println("joe".hello)
    Hello, Joe

定义多个扩展方法

为了定义更多的扩展方法,直接将他们放在extension下面即可:

    extension (s: String)
        def hello: String = s"Hello, ${s.capitalize}"
        def increment: String = s.map(c => (c + 1).toChar)
        def hideAll: String = s.replaceAll(".", "*")

下面的例子展示了如何使用这些扩展方法:

    "joe".hello         // Hello, Joe
    "hal".increment     // ibm
    "password".hideAll  // ********

携带参数的扩展方法

想要创建一个携带参数的扩展方法,可以像这样:

    extension (s: String)
        def makeInt(radix: Int): Int = Integer.parseInt(s, radix)

下面的例子展示了如何使用makeInt方法:

    "1".makeInt(2)   // Int = 1
    "10".makeInt(2)  // Int = 2
    "100".makeInt(2) // Int = 4

    "1".makeInt(8)   // Int = 1
    "10".makeInt(8)  // Int = 8
    "100".makeInt(8) // Int = 64

    "foo".makeInt(2) // java.lang.NumberFormatException

注意最后一个例子是想表明,此方法不能正确处理错误的字符串输入。

讨论

接下来使用最开始的hello方法来简单描述一下扩展方法(extension method)在Scala 3中的实现原理:

  • 编译器发现一个String常量。

  • 编译器发现正在尝试调用Stringhello方法。

  • 由于String类中没有hello方法,编译器开始在作用域中寻找一个含有Sting参数和返回一个Stringhello方法。

  • 编译器发现了扩展方法。

上面的描述做了很多简化,但能够让人大致的了解其中的实现原理。

另见

  • 关于Scala 3中更多扩展方法的内容,参阅官方文档( https://oreil.ly/rzzZ0 )。

  • 关于Scala 2中实现扩展方法的对比,可以参考我的文章“Implicit Methods/Functions in Scala 2 and 3 (Dotty Extension Methods)”( https://oreil.ly/nN7h7 )。

最后更新于

这有帮助吗?