You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

27 KiB

06 | switch表达式怎么简化多情景操作

你好我是范学雷。今天我们聊一聊Switch表达式。

switch表达式这个特性首先在JDK 12中以预览版的形式发布。在JDK 13中改进的switch表达式再次以预览版的形式发布。最后switch表达式在JDK 14正式发布。

不论你学习什么样的编程语言合理地分析、判断、处理不同的情况都是必备的基本功。比如我们使用的if-else语句还有switch语句都是用来处理种种不同的情况的。 我们都知道switch语句那么switch表达式又是什么呢switch语句和switch表达式又有什么不同呢

如果你了解了Java的语句和表达式这两个基本概念你的困扰也许会少一点。Java规范里表达式完成对数据的操作。一个表达式的结果可以是一个数值i * 4或者是一个变量i = 4或者什么都不是void类型

Java语句是Java最基本的可执行单位它本身不是一个数值也不是一个变量。Java语句的标志性符号是分号代码和双引号代码块比如if-else语句赋值语句等。这样再来看就很简单了switch表达式就是一个表达式而switch语句就是一个语句。

switch表达式是什么样子的为什么需要switch表达式我们还是通过案例和代码一点一点地来学习switch表达式吧。

阅读案例

在讲解或者学习switch语句时每年的十二个月或者每周的七天是我们经常使用的演示数据。在这个案例里我们也使用这样的数据来看看传统的switch语句有哪些需要改进的地方。

下面,我们要讨论的,也是一个传统的问题: 该怎么用代码计算一个月有多少天?生活中,我们熟悉这样的顺口溜,“一三五七八十腊,三十一天永不差,四六九冬三十整,平年二月二十八,闰年二月把一加”。

下面的这段代码,就是按照这个顺口溜的逻辑来计算了一下,今天所在的这个月,一共有多少天。

package co.ivi.jus.swexpr.former;

import java.util.Calendar;

class DaysInMonth {
    public static void main(String[] args) {
        Calendar today = Calendar.getInstance();
        int month = today.get(Calendar.MONTH);
        int year = today.get(Calendar.YEAR);

        int daysInMonth;
        switch (month) {
            case Calendar.JANUARY:
            case Calendar.MARCH:
            case Calendar.MAY:
            case Calendar.JULY:
            case Calendar.AUGUST:
            case Calendar.OCTOBER:
            case Calendar.DECEMBER:
                daysInMonth = 31;
                break;
            case Calendar.APRIL:
            case Calendar.JUNE:
            case Calendar.SEPTEMBER:
            case Calendar.NOVEMBER:
                daysInMonth = 30;
                break;
            case Calendar.FEBRUARY:
                if (((year % 4 == 0) && !(year % 100 == 0))
                        || (year % 400 == 0)) {
                    daysInMonth = 29;
                } else {
                    daysInMonth = 28;
                }
                break;
            default:
                throw new RuntimeException(
                    "Calendar in JDK does not work");
        }

        System.out.println(
            "There are " + daysInMonth + " days in this month.");
    }
}

这段代码里我们使用了switch语句。代码本身并没有什么错误但是至少有两个容易犯错误的地方。

第一个容易犯错的地方就是在break关键字的使用上。上面的代码里如果多使用一个break关键字代码的逻辑就会发生变化同样的少使用一个break关键字也会出现问题。

int daysInMonth;
switch (month) {
    case Calendar.JANUARY:
    case Calendar.MARCH:
    case Calendar.MAY:
        break;    // WRONG BREAK!!!
    case Calendar.JULY:
    case Calendar.AUGUST:
    case Calendar.OCTOBER:
    case Calendar.DECEMBER:
        daysInMonth = 31;
        break;
    // snipped
}

int daysInMonth;
switch (month) {
    // snipped
    case Calendar.APRIL:
    case Calendar.JUNE:
    case Calendar.SEPTEMBER:
    case Calendar.NOVEMBER:
        daysInMonth = 30;
                         // WRONG, NO BREAK!!!
    case Calendar.FEBRUARY:
        if (((year % 4 == 0) && !(year % 100 == 0))
                || (year % 400 == 0)) {
            daysInMonth = 29;
        } else {
            daysInMonth = 28;
        }
        break;
    // snipped
}

break语句的遗漏或者冗余这样的错误如此得常见甚至于被单列成了一个常见软件安全漏洞。凡是使用switch语句的代码都有可能成为黑客们重点关注的对象。由于逻辑的错误和黑客的特殊关照我们在编写代码的时候需要十二分的小心阅读代码的时候也需要反复地查验break语句的前后语境。毫无疑问这增加了代码维护的成本降低了生产效率。

为什么switch语句里需要使用break呢最主要的原因就是希望能够在不同的情况下共享部分或者全部的代码片段。比如上面的例子中四月、六月、九月、十一月这四种情景可以共享每个月都是30天这样的代码片段。这个代码片段只需要写在十一月情景的后面前面的四月、六月和九月这三个情景都会顺次执行下面的操作fall-through直到遇到下一个break语句或者switch语句终结。

现在我们都知道了这样是一个弊大于利的设计。但很遗憾Java初始的设计就是采用了这样的设计思想。如果要新设计一门现代的语言我们需要更多地使用switch语句但是就不要再使用break语句了。不过不同的情景共享代码片段仍然是一个真实的需求。在废弃掉break语句之前我们要找到在不同的情景间共享代码片段的新规则。

第二个容易犯错的地方,是反复出现的赋值语句。 在上面的代码中,daysInMonth这个本地变量的变量声明和实际赋值是分开的。赋值语句需要反复出现以适应不同的情景。如果在switch语句里daysInMonth变量没有被赋值,编译器也不会报错,缺省的或者初始的变量值就会被使用。

int daysInMonth = 0;
switch (month) {
    // snipped
    case Calendar.APRIL:
    case Calendar.JUNE:
    case Calendar.SEPTEMBER:
    case Calendar.NOVEMBER:
        break;   // WRONG, INITIAL daysInMonth value IS USED!!!
    case Calendar.FEBRUARY:
    // snipped
}

在上面的例子里初始的变量值不是一个合适的数据当然在另外一个例子里缺省的或者初始的变量值也可能就是一个合适的数据了。为了判断这个本地变量有没有合适的值我们需要通览整个switch语句块确保赋值没有遗漏也没有多余。这增加了编码出错的几率也增加了阅读代码的成本。

那么,能不能让多情景处理的代码块拥有一个数值呢? 或者换个说法多情景处理的代码块能不能变成一个表达式这个想法就催生了Java语言的新特性“switch表达式”。

switch表达式

switch表达式是什么样子的呢下面的这段代码使用的就是switch表达式它改进了上面阅读案例里的代码。你可以带着上面遇到的问题来阅读这段代码。这些问题包括

  • switch表达式是怎么表示一个数值从而可以给变量赋值的
  • 在不同的情景间switch表达式是怎么共享代码片段的
  • 使用switch表达式的代码有没有变得更简单、更皮实、更容易理解
package co.ivi.jus.swexpr.modern;

import java.util.Calendar;

class DaysInMonth {
    public static void main(String[] args) {
        Calendar today = Calendar.getInstance();
        int month = today.get(Calendar.MONTH);
        int year = today.get(Calendar.YEAR);

        int daysInMonth = switch (month) {
            case Calendar.JANUARY,
                 Calendar.MARCH,
                 Calendar.MAY,
                 Calendar.JULY,
                 Calendar.AUGUST,
                 Calendar.OCTOBER,
                 Calendar.DECEMBER -> 31;
            case Calendar.APRIL,
                 Calendar.JUNE,
                 Calendar.SEPTEMBER,
                 Calendar.NOVEMBER -> 30;
            case Calendar.FEBRUARY -> {
                if (((year % 4 == 0) && !(year % 100 == 0))
                        || (year % 400 == 0)) {
                    yield 29;
                } else {
                    yield 28;
                }
            }
            default -> throw new RuntimeException(
                    "Calendar in JDK does not work");
        };

        System.out.println(
                "There are " + daysInMonth + " days in this month.");
    }
}

**我们最先看到的变化就是switch代码块出现在了赋值运算符的右侧。**这也就意味着这个switch代码块表示的是一个数值或者是一个变量。换句话说这个switch代码块是一个表达式。

int daysInMonth = switch (month) {
    // snipped
}

**我们看到的第二个变化是多情景的合并。也就是说一个case语句可以处理多个情景。**这些情景使用逗号分隔开来共享一个代码块。而传统的switch代码一个case语句只能处理一种情景。

case Calendar.JANUARY,
     Calendar.MARCH,
     // snipped

多情景的合并的设计满足了共享代码片段的需求。而且由于只使用一个case语句也就不再需要使用break语句来满足这个需求了。所以break语句从switch表达式里消失了。

不同之处在于传统的switch代码不同的case语句之间可以共享部分的代码片段而switch表达式里需要共享全部的代码片段。这看似是一个损失但其实共享部分代码片段的能力给代码的编写者带来的困惑远远多于它带来的好处。如果需要共享部分的代码片段我们总是可以找到替换的办法比如把需要共享的代码封装成更小的方法。所以我们没有必要担心switch表达式不支持共享部分代码片段。

下一个变化,是一个新的情景操作符,“->”,它是一个箭头标识符。这个符号使用在case语句里一般化的形式是“case L ->”。这里的L就是要匹配的一个或者多个情景。如果目标变量和情景匹配那么就执行操作符右边的表达式或者代码块。如果要匹配的情景有两个或者两个以上就要使用逗号“,”用分隔符把它们分割开来。

case Calendar.JANUARY,
     // snipped
     Calendar.DECEMBER -> 31;

传统的switch代码这个一般化的形式是“case L 也就是使用冒号标识符。为什么不延续使用传统的情景操作符呢这主要是出于简化代码的考虑。我们依然可以在switch表达式里使用冒号标识符使用冒号标识符的一个case语句只能匹配一个情景这种情况我们稍后再讨论。

**下一个我们看到的变化是箭头标识符右侧的数值。这个数值代表的就是该匹配情景下switch表达式的数值。**需要注意的是,**箭头标识符右侧可以是表达式、代码块或者异常抛出语句,而不能是其他的形式。**如果只需要一个语句,这个语句也要以代码块的形式呈现出来。

case Calendar.JANUARY,
     // snipped
     Calendar.DECEMBER -> {  // CORRECT, enclosed with braces.
    yield 31;
}

没有以代码块形式呈现的代码,编译的时候,就会报错。这是一个很棒的约束。代码块的形式,增强了视觉效果,减少了编码的失误。在《代码精进之路》这个专栏里,我们反复强调过这种形式的好处。

case Calendar.JANUARY,
     // snipped
     Calendar.DECEMBER ->   // WRONG, not a block.
    yield 31;

另外箭头标识符右侧需要一个表达switch表达式的数值这是一个很强的约束。如果一个语句破坏了这个需要它就不能出现在switch表达式里。比如下面的代码里的return语句意图退出该方法而没有表达这个switch表达式的数值。这段代码就不能通过编译器的审查。

int daysInMonth = switch (month) {
    // snipped
    case Calendar.APRIL,
         // snipped
         Calendar.NOVEMBER -> {
        // yield 30;
        return; // WRONG, return outside of enclosing switch expression.
    }
    // snipped
}

最后一个我们能够看到的变化是出现了一个新的关键字“yield”。大多数情况下switch 表达式箭头标识符的右侧是一个数值或者是一个表达式。 如果需要一个或者多个语句,我们就要使用代码块的形式。这时候,我们就需要引入一个新的 yield 语句来产生一个值,这个值就成为这个封闭代码块代表的数值。

为了便于理解我们可以把yield语句产生的值看成是switch表达式的返回值。所以yield只能用在switch 表达式里而不能用在switch语句里。

case Calendar.FEBRUARY -> {
    if (((year % 4 == 0) && !(year % 100 == 0))
            || (year % 400 == 0)) {
        yield 29;
    } else {
        yield 28;
    }
}

其实这里还有一个我们从上述的代码里看不到的变化。在switch表达式里所有的情景都要列举出来不能多、也不能少这也就是我们常说的穷举

比如说在上面的例子里如果没有最后的default情景分支编译器就会报错。这是一个影响深远的改进它会使得switch表达式的代码更加健壮大幅度降低维护成本如果未来需要增加一个情景分支的话就更是如此了。

int daysInMonth = switch (month) {
    case Calendar.JANUARY,
         // snipped
         Calendar.DECEMBER -> 31;
    case Calendar.APRIL,
         // snipped
         Calendar.NOVEMBER -> 30;
    case Calendar.FEBRUARY -> {
             // snipped
    }
    // WRONG to comment out the default branch, 'switch' expression
    // MUST cover all possible input values.
    //
    // default -> throw new RuntimeException(
    //        "Calendar in JDK does not work");
};

改进的switch语句

通过上面的解读我们知道了switch表达式里有很多积极的变化。那这些变化有没有影响switch语句呢比如说我们能够在switch语句里使用箭头标识符吗我们前面说过yield语句是来产生一个switch表达式代表的数值的因此yield语句只能用在switch表达式里不能用在switch语句。

其他的变化呢?我们还是先来看下面一段代码。

private static int daysInMonth(int year, int month) {
    int daysInMonth = 0;
    switch (month) {
        case Calendar.JANUARY,
             Calendar.MARCH,
             Calendar.MAY,
             Calendar.JULY,
             Calendar.AUGUST,
             Calendar.OCTOBER,
             Calendar.DECEMBER ->
            daysInMonth = 31;
        case Calendar.APRIL,
             Calendar.JUNE,
             Calendar.SEPTEMBER,
             Calendar.NOVEMBER ->
            daysInMonth = 30;
        case Calendar.FEBRUARY -> {
            if (((year % 4 == 0) && !(year % 100 == 0))
                    || (year % 400 == 0)) {
                daysInMonth = 29;
                break;
            }

            daysInMonth = 28;
        }
        // default -> throw new RuntimeException(
        //        "Calendar in JDK does not work");
    }

    return daysInMonth;
}

在这段代码里我们看到了箭头标识符看到了break语句看到了注释掉的default语句。这是一段合法的、能够工作的代码。换个说法switch语句可以使用箭头标识符也可以使用break语句也不需要列出所有的情景。表面上看起来switch语句的改进不是那么显而易见。其实switch语句的改进主要体现在break语句的使用上。

我们应该也看到了break语句没有出现在下一个case语句之前。这也就意味着使用箭头标识符的switch语句不再需要break语句来实现情景间的代码共享了。虽然我们还可以这样使用break语句但是已经不再必要了。

switch (month) {
    // snipped
    case Calendar.APRIL,
             // snipped
         Calendar.NOVEMBER -> {
            daysInMonth = 30;
            break;  // UNNECESSARY, could be removed safely.
        }
    // snipped
}

有没有break语句使用箭头标识符的switch语句都不会顺次执行下面的操作fall-through。这样我们前面谈到的break语句带来的烦恼也就消失不见了。

不过使用箭头标识符的switch语句并没有禁止break语句而是恢复了它本来的意义从代码片段里抽身就像它在循环语句里扮演的角色一样。

switch (month) {
   // snipped
   case Calendar.FEBRUARY -> {
        if (((year % 4 == 0) && !(year % 100 == 0))
                || (year % 400 == 0)) {
            daysInMonth = 29;
            break;     // BREAK the switch statement
        }
    
        daysInMonth = 28;
    }
   // snipped
}

怪味的switch表达式

我们前面说过switch表达式也可以使用冒号标识符。使用冒号标识符的一个case语句只能匹配一个情景而且支持fall-through。和箭头标识符的switch表达式一样使用冒号标识符switch表达式也不支持break语句取而代之的是yield语句。

这是一个充满了怪味道的编码形式,我并不推荐使用这种形式,但我可以带你略作了解。下面的这段代码,就是我们试着把箭头标识符替换成冒号标识符的一个例子。你可以比较一下使用冒号标识符和箭头标识符的两段代码,想一想两种不同形式的优劣。毫无疑问,使用箭头标识符的代码更加简洁。

package co.ivi.jus.swexpr.legacy;

import java.util.Calendar;

class DaysInMonth {
    public static void main(String[] args) {
        Calendar today = Calendar.getInstance();
        int month = today.get(Calendar.MONTH);
        int year = today.get(Calendar.YEAR);

    int daysInMonth = switch (month) {
        case Calendar.JANUARY:
        case Calendar.MARCH:
        case Calendar.MAY:
        case Calendar.JULY:
        case Calendar.AUGUST:
        case Calendar.OCTOBER:
        case Calendar.DECEMBER:
            yield 31;
        case Calendar.APRIL:
        case Calendar.JUNE:
        case Calendar.SEPTEMBER:
        case Calendar.NOVEMBER:
            yield 30;
        case Calendar.FEBRUARY:
            if (((year % 4 == 0) && !(year % 100 == 0))
                    || (year % 400 == 0)) {
                yield 29;
            } else {
                yield 28;
            }
        default:
            throw new RuntimeException(
                    "Calendar in JDK does not work");
        };

        System.out.println(
            "There are " + daysInMonth + " days in this month.");
    }
}

有了使用箭头标识符的switch语句和switch表达式之后我们不再推荐使用冒号标识符的switch语句和switch表达式。学习并使用箭头标识符的switch语句和switch表达式会使代码更简洁、更健壮。

总结

到这里我来做个小结。从前面的讨论中我们重点了解了switch表达式和改进的switch语句。我们还讨论了switch表达式带来的新概念和新的关键字了解了这些基本概念以及它们的适用范围。

新的switch形式、语句和表达式不同的使用范围这些概念交织在一起让switch的学习和使用都变成了一件有点挑战性的事情。箭头标识符的引入简化了代码提高了编码效率。可是学习这么多种switch的表现形式也增加了我们的学习负担。为了帮助你快速掌握这些形式我把不同的switch表达形式以及它们支持的特征放在了下面这张表格里。

图片

或者,你也可以记住下面的总结:

  • break语句只能出现在switch语句里不能出现在switch表达式里
  • yield语句只能出现在switch表达式里不能出现在switch语句里
  • switch表达式需要穷举出所有的情景而switch语句不需要情景穷举
  • 使用冒号标识符的swtich形式支持情景间的fall-through而使用箭头标识符的swtich形式不支持fall-through
  • 使用箭头标识符的swtich形式一个case语句支持多个情景而使用冒号标识符的swtich形式不支持多情景的case语句。

使用箭头标识符的swtich形式废止了容易出问题的fall-through这个特征。因此我们推荐使用箭头标识符的swtich形式逐步废止使用冒号标识符的swtich形式。在switch表达式和switch语句之间我们应该优先使用switch表达式。这些选择都可以帮助我们简化代码逻辑减少代码错误提高生产效率。

如果你要丰富你的代码评审清单,学习完这一节内容后,你可以加入下面这一条:

使用冒号标识符的swtich形式是不是可以更改为使用箭头标识符
使用switch语句赋值的操作是不是可以更改为使用switch表达式

另外,我还拎出了几个今天讨论过的技术要点,这些都可能在你们面试中出现哦。通过这一次学习,你应该能够:

  • 知道switch表达式并且能够使用switch表达式
    • 面试问题你知道switch表达式吗该怎么处理switch表达式里的语句
  • 了解switch表达式要解决的问题并且知道解决掉这些问题的办法
    • 面试问题使用switch表达式有哪些好处
  • 了解不同的switch的表现形式能够看得懂不同的表现形式并且给出改进意见。
    • 面试问题:你更喜欢使用箭头标识符还是冒号标识符?

如果你能够有意识地使用箭头标识符的switch表达式应该可以大幅度提高编码的效率和质量如果你能够了解不同的switch表现形式并且对每种形式都有自己的见解你就能帮助你的同事提高编码的效率和质量。毫无疑问在面试的时候有意识地在代码里使用switch表达式是一个能够展现你的学习能力、理解能力和对新知识的接受能力的一个好机会。

思考题

在前面的讨论里我们说过情景穷举是一个影响深远的改进方向它会使得switch表达式的代码更加健壮大幅度降低维护成本特别是在未来需要增加一个情景分支的情形下。但是限于篇幅我们并没有详细地展开讨论其中的细节。现在我们把这个讨论当作一个稍微有点挑战的思考题。

假设有一天地球和太阳的关系发生了变化这种变化还没有大到毁灭人类的程度但是也足以改变年月的关系了。于是天文学家重新修订了日历增加了一个新的月份第十三个月。为了对应这种变化JDK的设计者们也给Calendar类增加了第十三个月Calendar.AFTERDEC。那么我们的问题就来了。

第一个问题是,我们现在的代码能够检测到这个变化吗?如果不能,是不是只有系统崩溃的时候,我们才能够意识到问题的存在?

第二个问题是,有没有更健壮的设计,能够帮助我们在系统崩溃之前就能够检测到这个意想不到的变化?从而给我们留出时间更改我们的代码和系统?

稍微提示一个,解决这个问题的其中一个思路,就是要使用有穷举能力的表达式,然后设计出可以表达穷举情景的新形式,而不是使用泛泛的整数来表达十二个月。

我在下面的例子中写了一个代码小样。这个代码小样,实现的还是一年只有十二个月的逻辑。现在我们假设,一年还是十二个月,但是我们想让这段代码健壮到能够检测到未来一年变成十一个月或者十三个月的情景。

在这个代码小样里,我也试着加入了一些提示。当然,你也可以试着找找其他的解决方案。请试着将这段代码修改成你喜欢的样子,让我们一起看看怎么解决掉这个问题。

package co.ivi.jus.swexpr.review.xuelei;

import java.util.Calendar;

class DaysInMonth {
    public static void main(String[] args) {
        Calendar today = Calendar.getInstance();
        int month = today.get(Calendar.MONTH);
        int year = today.get(Calendar.YEAR);

    // Hints: could we replace the integer month
    // with an exhaustive enumeration?
    int daysInMonth = switch (month) {
            case Calendar.JANUARY,
                 Calendar.MARCH,
                 Calendar.MAY,
                 Calendar.JULY,
                 Calendar.AUGUST,
                 Calendar.OCTOBER,
                 Calendar.DECEMBER -> 31;
            case Calendar.APRIL,
                 Calendar.JUNE,
                 Calendar.SEPTEMBER,
                 Calendar.NOVEMBER -> 30;
            case Calendar.FEBRUARY -> {
                if (((year % 4 == 0) && !(year % 100 == 0))
                        || (year % 400 == 0)) {
                    yield 29;
                } else {
                    yield 28;
                }
            }
            // Hints: Are we able to replace the default case by
            // enumerating all cases with case clause above?
            default -> throw new RuntimeException(
                    "Calendar in JDK does not work");
        };

        System.out.println(
            "There are " + daysInMonth + " days in this month.");
    }
}

欢迎你在留言区留言、讨论,分享你的阅读体验以及你对这个思考题的想法。

注:本文使用的完整的代码可以从GitHub下载,你可以通过修改GitHubreview template代码,完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见,请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在实例匹配专用的代码评审目录建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在swexpr/review/xuelei的目录下面。