LW Leon 学习

在纸上写 Java 代码

用笔写 Java 代码:没有编译器,没有自动补全,也不能运行。五节短课:在脑子里运行程序、画栈和堆、背熟代码骨架、抓住编译器本来会抓的小错,以及给自己的答案打分。

开始前需要什么

  • 你以前编译并运行过一个小的 Java 程序。如果没有,请先读 Java 入门指南。本篇从“身边没有计算机帮你检查”这一步开始。
  • 一支笔或铅笔、一块橡皮,以及方格纸或横线纸。在方格纸上画表格更方便。
  • 每课大约 20 分钟。检查题要写在纸上,不要只在脑子里想。
01

像计算机一样读代码

学完你能 你能一行一行地手动运行一小段 Java 程序,并写下它用到的每一个值。

在纸上写代码时,没有任何东西会告诉你某一行写错了。你唯一的检查办法,是自己把程序运行一遍:一半在脑子里,一半写在纸上。这叫手动跟踪(英文叫 dry run):你扮演计算机,拿着笔,一行一行地运行程序。

计算机不知道你的程序是做什么用的。它严格按每一行写的内容,按顺序执行,并且只记得变量里存着的东西。变量就是一个有名字的位置,里面存着一个值。你在跟踪时也要这样做:按这一行实际写的内容去做,而不是按你原本想写的内容去做。

用的工具叫跟踪表。跟踪表里每个变量占一列,每执行一行代码占一行。分四步来做。

1. 在页边给程序的每一行编号,从 1 开始。
2. 画表格。第一列是“行号”,接着每个变量一列,最后一列叫“备注”,用来写发生了什么,例如“条件为真”,或者程序输出了什么。
3. 开始运行。程序从 main 里的第一行开始执行。每执行一行,就加一行记录:先写行号,再写这一行执行完之后每个变量的值。没有变化的值要从上一行抄下来,这样每一行单独看也能读懂。
4. 对没有值的变量,用两种记号。短横线(–)表示这个变量还没有声明,问号(?)表示它已经声明,但还没有存进任何值。Java 不会编译读取问号变量的程序,所以在纸上,读取问号变量是一个你必须自己抓住的错误。

现在先把 String 变量在它那一列里写成带双引号的文字,例如 "data"。下一课会画出它背后真正发生的事。

循环语句会执行很多次,所以会有很多行记录。对于 for 循环,每当计算机走到条件判断,就加一行:写下更新部分(例如 i++)之后各变量的值,并在备注里写条件判断的结果。循环的最后一行记录,是条件为假的那一次,这之后循环体不会再执行。在 for 括号里声明的变量(例如 i)在循环结束后就不存在了,所以之后要写短横线。

if 语句只占一行记录。如果条件为假,下一行记录就是这个 if 右花括号之后的第一行代码。

如果程序调用了方法,就为这次调用另画一张小表,列出它的参数和它自己的变量,并给每次调用编号(f#1、f#2)。方法返回时,把返回值写在发起调用的那一行的备注里。

新词

手动跟踪(dry run)
不用计算机,拿笔手动运行一个程序。
跟踪表
一张表格:每个变量一列,每执行一行代码一行。
变量
一个有名字的位置,存放一个值,例如 int count。
条件判断
if 或 for 里那个是或否的问题,例如 i < 4,答案是真或假。
方法
一段有名字、可以被调用的代码,例如 length() 或 charAt(i)。

完整例子

Java 8+
public class Main {
    public static void main(String[] args) {
        int count = 0;
        String word = "data";
        for (int i = 0; i < word.length(); i++) {
            if (word.charAt(i) == 'a') {
                count++;
            }
        }
        System.out.println(count);
    }
}
先预测输出,再看结果

预期输出

2

逐步讲解

先做准备。给代码编号 1 到 12:public class Main { 是 1,public static void main 是 2,int count = 0; 是 3,最后一个 } 是 12。页面不显示行号,你要自己数。画五列:行号wordicount备注。计算机从 main 里的第一行开始,也就是第 3 行。
第 3 行word = –i = –count = 0创建 count 并存入 0。word 和 i 还不存在。
第 4 行word = "data"i = –count = 0创建 word,里面是文字 "data"。
第 5 行word = "data"i = 0count = 0for 这一行创建 i 并存入 0。word.length() 是 4,条件 0 < 4 为真。
第 6 行i = 0count = 0word.charAt(0) 是 d,不是 a,条件为假。下一行记录又回到 for 那一行。
第 5 行i = 1count = 0i++ 使 i 变成 1。条件 1 < 4 为真。
第 6 行i = 1count = 0word.charAt(1) 是 a,条件为真,进入 if 内部。
第 7 行i = 1count = 1count++ 使 count 加 1。
第 5 行i = 2count = 1i++ 使 i 变成 2。条件 2 < 4 为真。
第 6 行i = 2count = 1word.charAt(2) 是 t,条件为假。
第 5 行i = 3count = 1i++ 使 i 变成 3。条件 3 < 4 为真。
第 6 行i = 3count = 1word.charAt(3) 是 a,条件为真。
第 7 行i = 3count = 2count++ 使 count 变成 2。
第 5 行i = 4count = 2i++ 使 i 变成 4。条件 4 < 4 为假,循环结束。
第 10 行i = –count = 2System.out.println 输出 2 和一个换行。i 在循环结束时就不存在了,所以写短横线。程序输出 2。

常见错误

错误写法
double half = 7 / 2;

7 和 2 都是 int,所以 Java 先做整数除法。7 / 2 的结果是 3,然后 Java 把 3 存成 3.0,小数部分早就没有了。跟踪时要按程序里实际的类型来算,而不是按你希望的数字来算。让至少一边是 double:7 / 2.0 等于 3.5。

正确写法
double half = 7 / 2.0;
错误写法
String word = "data";
for (int i = 0; i <= word.length(); i++) {
    System.out.println(word.charAt(i));
}

条件用的是 <=,所以 i 等于 4 时循环还会执行。但长度为 4 的 String 只有 0 到 3 号位置。word.charAt(4) 会让程序因 StringIndexOutOfBoundsException 而停止。跟踪表的最后一行就能看出来:这个位置在单词之外。要访问每个位置,应该在 length() 上用 <。

正确写法
String word = "data";
for (int i = 0; i < word.length(); i++) {
    System.out.println(word.charAt(i));
}
错误写法
System.out.println('a' + 1);

char 参与算术运算时,用的是它的数字编码。'a' 是 97,所以 'a' + 1 是 int 型的 98,打印出来的就是 98。要得到下一个字母,需要把和强制转换回 char。

正确写法
System.out.println((char) ('a' + 1));
错误写法
System.out.println("sum: " + 1 + 2);

Java 的 + 从左往右算。"sum: " + 1 得到文字 sum: 1,再 + 2 又接上一段文字,所以程序输出 sum: 12。把加法放进圆括号,让它先算:"sum: " + (1 + 2) 输出 sum: 3。

正确写法
System.out.println("sum: " + (1 + 2));

检查自己

  1. 为下面四行代码(编号 1 到 4)做一张跟踪表:int a = 6; int b = 4; a = a % b; b = b + a; 最后 a 和 b 各是多少?

    先自己做,再看答案

    第 1 行:a = 6,b = –。第 2 行:a = 6,b = 4。第 3 行:a = 2,b = 4,因为 6 % 4 是 2。第 4 行:a = 2,b = 6。最后 a 是 2,b 是 6。第 4 行用的是 a 的新值。

  2. 不运行,直接预测:for (int i = 1; i < 10; i = i * 3) System.out.print(i + " "); 会输出什么?

    先自己做,再看答案

    输出 1 3 9,每个数后面有一个空格。9 之后更新部分使 i 变成 27,条件 27 < 10 为假,循环结束。步进不一定是 +1。

  3. 在上面的完整例子里,第 10 行那一行记录中,i 这一列应该写什么?为什么?

    先自己做,再看答案

    写短横线。i 是在 for 那一行声明的,只在循环运行期间存在。循环结束后它就消失了,第 10 行的代码也不能使用它。

02

画出内存

学完你能 你能用一套固定的画法,画出 Java 程序在任意一行时的栈和堆,并说出哪些对象已经不可达。

Java 程序处理的是对象,而且好几个变量可以通向同一个对象。这一点从代码里看不出来,必须画出来。每次都用同一种画法。

Java 把值放在两个地方。把它们画成两列,每列写一个标题:栈和堆。

栈为当前正在运行的每个方法放一个栈帧。栈帧是一个方框,里面放这次调用的参数和局部变量。先画 main 的栈帧,放在栈这一列的最上面。每调用一次方法,就在它下面加一个新栈帧。方法返回时,把它的栈帧划掉,里面的东西全部消失。

堆放所有的对象和数组。它们都是用 new 创建的。你不需要释放它们,Java 会替你做。

下面是画图的规则。

1. 基本类型(int、double、boolean、char)的变量是一个方框,值写在里面,例如 int n 和 5。
2. 其他类型的变量里存的是引用。引用要么是指向对象的箭头,要么是 null。画一支箭头,同时在方框里写上目标的标签,这样箭头交叉时你仍然能读出来。
3. 给每个对象加标签:H1、H2、H3,按 new 被执行的顺序编号。在对象上方写标签和类名,例如 H1 (Box)。对象的字段画成里面的小方框,并写上字段名。数组画成 H2 (int[3]),带有 [0]、[1]、[2] 三个单元。新数组的单元一开始是 0(数值)、false,或者 null(引用),绝不会是 ?。
4. 对象数组是引用的数组。new Box[2] 只创建一个数组,它的两个单元里是 null,并不会创建任何 Box。每个 Box 都需要自己的 new。
5. String 是对象。把它画在堆里,写上它的文字,例如 H3 (String) "hi"。文字相同的两个字面量是同一个对象。new String("hi") 会创建另一个对象。
6. null 是不指向任何东西的引用。在方框上画一道斜线并写上 null。通过 null 调用方法,程序会因 NullPointerException 而停止。

= 做了什么。a = b 把 b 方框里的内容复制到 a 的方框里。对基本类型,复制的是数值;对引用,复制的是箭头。执行 Box c = a; 之后,c 和 a 通向同一个对象,对象本身并没有被复制。引用之间的 == 问的是:两支箭头是否指向同一个对象。它不会去看对象里面。要比较两个 String 的文字,用 equals。

方法调用做了什么。它把每个实参复制到新栈帧里新的参数方框中。Java 永远是复制。对引用来说,复制的是箭头。所以方法可以顺着箭头修改对象,但不能让调用者的变量指向别的地方。

什么变成了不可达。如果你能从仍然存在的栈帧里的某个变量出发,顺着箭头走到一个对象,它就是可达的。没有任何箭头通向的对象是不可达的。垃圾回收器(Java 里负责回收内存的部分)可以在某个时刻把它清除。在纸上,要在对象变成不可达的那一刻把它划掉:也就是通向它的最后一支箭头被改掉,或者持有最后一支箭头的栈帧被划掉的时候。

新词

栈和栈帧
栈为每个正在运行的方法放一个栈帧。栈帧里是这次调用的参数和局部变量,调用返回时就消失。
堆
所有对象和数组所在的地方。你用 new 创建它们,Java 会替你清除它们。
引用
对象类型变量里存的内容:一支指向对象的箭头,或者 null。
空值 null
不指向任何东西的引用。通过它调用方法是错误。
标签
你在图里给对象起的名字,例如 H1。
不可达
没有任何来自存活栈帧的箭头通向的对象。垃圾回收器可以清除它。
垃圾回收器
Java 中负责找出不可达对象并回收其内存的部分。

完整例子

Java 8+
public class Main {
    static class Box {
        int value;

        Box(int value) {
            this.value = value;
        }
    }

    static void bump(Box b, int n) {
        b.value = b.value + n;
        n = 0;
        b = null;
        // A: draw the memory here, inside bump
    }

    public static void main(String[] args) {
        int n = 5;
        Box a = new Box(1);
        Box c = a;
        Box[] row = new Box[2];
        row[0] = new Box(7);
        bump(a, n);
        System.out.println(c.value + " " + n + " " + row[0].value + " " + row[1]);
        a = new Box(40);
        c = null;
        // B: draw the memory here
        System.out.println(a.value);
    }
}
先预测输出,再看结果

预期输出

6 5 7 null
40

逐步讲解

  1. 画两列并写上标题:栈和堆。省略 main 的参数 args,因为这个程序没有用到它。在栈的最上面画 main 的栈帧。

  2. int n = 5; 画一个方框 int n,里面是 5。它是基本类型,所以值直接放在方框里。

  3. Box a = new Box(1); new 在堆里创建一个对象:H1 (Box),里面有一个字段方框 value。构造方法运行,把 value 设为 1。变量 a 里是指向 H1 的箭头,方框里写上 H1。

  4. Box c = a; 复制箭头。c 里也是 H1。Box 对象仍然只有一个。

  5. Box[] row = new Box[2]; 创建一个数组对象 H2 (Box[2]),单元 [0] 和 [1] 里都是 null。并没有创建任何 Box。row 里是指向 H2 的箭头。

  6. row[0] = new Box(7); 创建第二个 Box,H3 (Box),value 是 7,并把指向 H3 的箭头放进 H2 的单元 [0]。单元 [1] 里仍然是 null。

  7. bump(a, n); 在 main 的栈帧下面画一个新栈帧。参数 b 是 a 里箭头的副本,所以 b 里是 H1。参数 n 是 5 的副本,是一个新方框,里面有它自己的 5。接着 b.value = b.value + n 顺着箭头走:H1 的 value 变成 1 + 5 = 6。n = 0 只改 bump 自己的 n。b = null 只把 null 放进 bump 自己的 b,main 里的 a 仍然是 H1。在标记 A 处:bump 里 b = null、n = 0,H1 的 value 是 6。然后 bump 返回:划掉它的栈帧。

  8. println 读取 c.value(c 通向 H1,里面是 6)、n(main 里的 n 仍然是 5)、row[0].value(7)和 row[1](null)。输出 6 5 7 null。

  9. a = new Box(40); 创建 H4 (Box),value 是 40,a 里的箭头移到 H4。H1 仍然可达,因为 c 通向它。接着 c = null; 在 c 上画一道斜线。现在没有任何箭头通向 H1:它不可达,把它划掉。在标记 B 处:main 里 n = 5,a 指向 H4,c = null,row 指向 H2。H2 的 [0] 指向 H3,[1] 是 null。H3 里是 7,H4 里是 40,H1(里面是 6)被划掉。

  10. 最后一个 println 输出 40。

常见错误

错误写法
String a = new String("hi");
String b = new String("hi");
boolean same = (a == b);

每个 new String("hi") 都会创建自己的对象,所以 a 和 b 里是指向两个不同对象 H1 和 H2 的箭头。== 比较的是箭头,所以即使两个对象的文字都是 hi,same 也是 false。要比较文字,用 a.equals(b)。

正确写法
String a = new String("hi");
String b = new String("hi");
boolean same = a.equals(b);
错误写法
int[] a = {1, 2, 3};
int[] b = a;
b[0] = 9;

int[] b = a; 复制的是箭头,不是数组。只有一个数组 H1,有两支箭头通向它,所以 b[0] = 9 也会改变 a[0]。要得到独立的数组,就要创建一个新对象:b = a.clone() 会画出带有自己单元的第二个数组 H2。

正确写法
int[] a = {1, 2, 3};
int[] b = a.clone();
b[0] = 9;
错误写法
Box[] row = new Box[2];
row[0].value = 3;

new Box[2] 只创建了数组。它的单元里是 null,而 null 没有 value 字段。顺着 row[0] 的箭头走,程序会因 NullPointerException 而停止。要先创建 Box。

正确写法
Box[] row = new Box[2];
row[0] = new Box(0);
row[0].value = 3;
错误写法
static void reset(Box b) {
    b = new Box(0);
}

参数 b 是调用者箭头的副本。b = new Box(0) 让副本指向新对象 H2,调用者的箭头纹丝不动。reset 返回后,调用者那边什么都没变。要顺着箭头去修改调用者能看到的那个对象。

正确写法
static void reset(Box b) {
    b.value = 0;
}

检查自己

  1. 画出来:int[] a = {1, 2}; int[] b = a; b[0] = 7; a = new int[]{5}; 每个变量各通向什么?第一个数组是不是不可达了?

    先自己做,再看答案

    第一个数组是 H1 (int[2])。执行 int[] b = a 之后,a 和 b 都通向 H1。b[0] = 7 使 H1 里变成 7 和 2。然后 new int[]{5} 创建 H2 (int[1]),里面是 5,a 移到 H2。b 仍然通向 H1,所以 H1 可达,不用划掉。现在 a[0] 是 5,b[0] 是 7。

  2. 画出来:String s = null; 然后调用 s.length()。图上是什么样子?会发生什么?

    先自己做,再看答案

    方框 s 里是 null:一道斜线,没有箭头。调用时要顺着箭头找到一个对象,但这里没有箭头。程序会因 NullPointerException 而停止。

  3. 画出来:Box x = new Box(1); Box y = new Box(1); Box z = x; x == y 和 x == z 哪个为真?(Box 没有自己的 equals 方法。)

    先自己做,再看答案

    一共有两个对象 H1 和 H2,里面都是 1。x 和 z 通向 H1,y 通向 H2。x == y 为假:虽然内容相同,但两支箭头指向不同的对象。x == z 为真:两支箭头指向同一个对象。

03

要背熟的语法

学完你能 你能凭记忆写出下面十一个 Java 骨架,从最短的开始,每个括号和分号都不缺。

有些代码没法当场推出来,你必须不看资料就知道它的形状。这一课的十一个骨架,是 Java 里反复要用的。它们都很短,可以背下来。按下面的顺序学,从最短的开始,上一个背牢了,再加下一个。

1. 类。2. main。3. 构造方法。4. 增强 for(for-each)。5. length、length() 和 size()。6. String 的方法。7. ArrayList。8. HashMap。9. try 和 catch。10. 泛型。11. interface 和 extends。

背诵的办法:盖住页面,在纸上默写这个骨架,再和页面逐个字符对照。只有完全对、中途没有偷看,才打勾。明天再来一遍。连续三天都能写对的骨架,才算学会。

下面的程序包含全部十一个骨架,每个骨架旁边有一条形如 (4) 的注释。先读一遍,再盖住它,在你自己的纸上把每个骨架单独默写出来,每个只写几行。

“有多少个”的三种写法。数组有的是字段,所以不带括号:nums.length。String 有的是方法,所以带括号:word.length()。ArrayList 或 HashMap 的方法叫 size():names.size()。一个记法:只有朴素的数组不带括号。

String 的方法。位置从 0 开始,而且 String 永远不会改变:每个方法都给你一个新值。length() 是字符个数。charAt(i) 是一个 char。substring(from, to) 取从位置 from 开始、到位置 to 之前(不含 to)的字符。indexOf("x") 是第一个 x 的位置,没有就是 -1。equals(other) 比较文字。toUpperCase() 返回一个大写的新 String。contains("x") 说明 x 是否出现过。

ArrayList:ArrayList<String> names = new ArrayList<>(); 常用 add(x)、get(i)、set(i, x)、remove(i)、size()、contains(x)。HashMap:HashMap<String, Integer> ages = new HashMap<>(); 常用 put(key, value)、get(key)、containsKey(key)、remove(key)、size()。尖括号里的类型必须是类:写 Integer,不能写 int。两者都需要在最上面写 import 行。

新词

骨架
一段代码固定不变的形状,细节留待填写。
构造方法
一种特殊的方法:与类同名,没有返回类型,在 new 创建对象时运行。
泛型
带有类型参数(例如 T)的类或方法,使用时再把具体类型填进去。
异常
中断正常流程的错误。catch 块可以处理它。
接口
一组没有方法体的方法名。实现它的类必须提供这些方法。

完整例子

Java 8+
import java.util.ArrayList;
import java.util.HashMap;

/* (11) interface and extends */
interface Shape {
    double area();
}

class Rect implements Shape {
    private double width;
    private double height;

    /* (3) constructor */
    Rect(double width, double height) {
        this.width = width;
        this.height = height;
    }

    public double area() {
        return width * height;
    }
}

class Square extends Rect {
    Square(double side) {
        super(side, side);
    }
}

/* (10) generics */
class Box<T> {
    private T item;

    Box(T item) {
        this.item = item;
    }

    T get() {
        return item;
    }
}

/* (1) a class */
public class Main {
    static <T extends Comparable<T>> T larger(T a, T b) {
        if (a.compareTo(b) >= 0) {
            return a;
        }
        return b;
    }

    /* (2) main */
    public static void main(String[] args) {
        /* (7) ArrayList */
        ArrayList<String> names = new ArrayList<>();
        names.add("Leon");
        names.add("Mia");
        names.add("Emma");

        /* (8) HashMap */
        HashMap<String, Integer> ages = new HashMap<>();
        ages.put("Leon", 20);
        ages.put("Mia", 22);

        /* (4) for-each */
        for (String name : names) {
            if (ages.containsKey(name)) {
                System.out.println(name + " is " + ages.get(name));
            } else {
                System.out.println(name + " has no age");
            }
        }

        /* (5) length, length() and size() */
        int[] nums = new int[3];
        String word = "banana";
        System.out.println(nums.length + " " + word.length() + " " + names.size());

        /* (6) String methods */
        System.out.println(word.substring(1, 3) + " " + word.indexOf("n") + " " + word.charAt(0) + " " + word.toUpperCase() + " " + word.equals("banana"));

        Shape s = new Square(3);
        System.out.println(s.area());
        System.out.println(larger("pear", "apple"));
        Box<Integer> box = new Box<>(7);
        System.out.println(box.get());

        /* (9) try and catch */
        try {
            int x = Integer.parseInt("12a");
            System.out.println(x);
        } catch (NumberFormatException e) {
            System.out.println("not a number");
        }
    }
}
先预测输出,再看结果

预期输出

Leon is 20
Mia is 22
Emma has no age
3 6 3
an 2 b BANANA true
9.0
pear
7
not a number

逐步讲解

  1. (1) 类。public class 名字 { ... } 先写 public,再写 class,然后是名字和花括号。名字必须和文件名一致:Main 放在 Main.java 里。一个文件里最多有一个 public 类。其他的类(Shape、Rect、Square、Box)前面不写 public,这是可以的。

  2. (2) main。public static void main(String[] args) { ... } 要一字不差地写出来。public 让系统能调用它,static 表示不需要对象,void 表示没有返回值,String[] args 是命令行传入的词。

  3. (3) 构造方法。类名(类型 参数) { this.字段 = 参数; } 它和类同名,没有返回类型,连 void 也不写。this.width = width; 把参数存进正在创建的对象的字段里。参数和字段可以同名,this. 表示选的是字段。

  4. (4) for-each。for (类型 元素 : 集合) { ... } 先写单个元素的类型,再写冒号,然后是数组或集合。这里是 for (String name : names)。把冒号读作“在……之中”。

  5. (5) length、length() 和 size()。nums.length 是 3(数组,不带括号)。word.length() 是 6(String,带括号)。names.size() 是 3(ArrayList,用带括号的 size)。这一行输出 3 6 3。

  6. (6) String 的方法。word.substring(1, 3) 是 an(位置 1 和 2)。word.indexOf("n") 是 2。word.charAt(0) 是 b。word.toUpperCase() 是 BANANA。word.equals("banana") 是 true。这一行输出 an 2 b BANANA true。

  7. (7) ArrayList。最上面写 import java.util.ArrayList;。ArrayList<String> names = new ArrayList<>(); 然后 names.add("Leon")。类型要写在等号两边,但右边的菱形 <> 里可以留空。

  8. (8) HashMap。最上面写 import java.util.HashMap;。HashMap<String, Integer> ages = new HashMap<>(); 然后 ages.put("Leon", 20)。有两个类型,先键后值。ages.get("Emma") 会得到 null,因为从没放入过 Emma;所以程序先用 containsKey 判断。

  9. (9) try 和 catch。try { 有风险的代码 } catch (异常类型 e) { 出错时做什么 }。Integer.parseInt("12a") 无法把这段文字读成数字,于是抛出 NumberFormatException,try 块里剩下的部分被跳过,执行 catch 块。程序输出 not a number。

  10. (10) 泛型。带类型参数的类:class Box<T> { private T item; ... T get() ... } T 代表任意一个类,Box<Integer> 把它填上。带上界的方法:static <T extends Comparable<T>> T larger(T a, T b)。尖括号部分写在返回类型之前。程序输出 7 和 pear。

  11. (11) interface 和 extends。interface Shape { double area(); } 列出没有方法体的方法。class Rect implements Shape 必须提供 area(),并且要写 public。class Square extends Rect 继承 Rect 的方法,它的构造方法以 super(side, side); 开头,去运行 Rect 的构造方法。Shape s = new Square(3); 是允许的,s.area() 输出 9.0。

常见错误

错误写法
int a = nums.length();
int b = word.length;
int c = names.length();

每一行都用错了三种写法中的一种。数组有字段 length,不带括号。String 有方法 length(),带括号。ArrayList 有方法 size()。这三个名字都不能用在另外两种类型上。

正确写法
int a = nums.length;
int b = word.length();
int c = names.size();
错误写法
String word = "banana";
String part = word.substring(1, 3);

substring(1, 3) 在位置 3 之前停止,所以得到两个字符 an,而不是三个。如果你想要从位置 1 开始的三个字符,第二个数字是 1 + 3 = 4。把第二个数字当成“停下来的位置”。

正确写法
String word = "banana";
String part = word.substring(1, 4);
错误写法
for (String name in names) {
    System.out.println(name);
}

Java 里没有 in。分隔符是冒号,而且元素的类型不能省略。按顺序写三部分:类型、名字、冒号、集合。

正确写法
for (String name : names) {
    System.out.println(name);
}
错误写法
class Rect {
    private double width;

    public void Rect(double width) {
        this.width = width;
    }
}

构造方法完全没有返回类型。前面加了 void,它就成了一个恰好叫 Rect 的普通方法,类里就没有带参数的构造方法了,new Rect(2.0) 这样的调用就编译不过。名字要直接跟在访问修饰词后面。

正确写法
class Rect {
    private double width;

    public Rect(double width) {
        this.width = width;
    }
}

检查自己

  1. 在纸上默写一个类 Counter:有一个 int 字段 count,一个把它设为 0 的构造方法,以及一个加 1 的方法 increment()。然后和骨架对照。

    先自己做,再看答案

    public class Counter { private int count; public Counter() { count = 0; } public void increment() { count++; } } 对照检查:构造方法没有返回类型;increment 用 void;花括号配对;字段后和 count++ 后有分号。

  2. 写出三行代码:声明一个叫 items 的 String 型 ArrayList,向里面加入 "pen",并打印里面有多少个元素。别忘了文件最上面那一行。

    先自己做,再看答案

    文件最上面写 import java.util.ArrayList;,然后 ArrayList<String> items = new ArrayList<>(); items.add("pen"); System.out.println(items.size()); 输出 1。

  3. 写一段 try 和 catch:把 String 变量 text 转成 int 变量 n;如果 text 不是数字,就把 n 设为 -1。

    先自己做,再看答案

    int n; try { n = Integer.parseInt(text); } catch (NumberFormatException e) { n = -1; } 编译器能通过,因为 n 在两条路径上都被赋了值。

04

手写时常见的错误

学完你能 你能把自己写的 Java 答案像编译器那样再读一遍,抓住本来会丢分的小错。

在计算机上,编译器会在第一个错误处拦住你,并告诉你是哪一行。在纸上,没有人拦你,而这些小错每一个都会丢分,尽管它们和你是否理解题目毫无关系。所以你必须自己当自己的编译器。

答案写完之后,再读第二遍。这一遍不要问逻辑对不对,只问形式对不对。照下面的清单,用笔逐项打勾。

1. 每条语句都以分号结尾。类、方法、if、for 的右花括号后面没有分号。
2. 每个 ( [ { 都有配对的另一半。把花括号成对地数一遍。
3. 每个变量名前面都有类型。String 的 S 要大写。
4. java.util 里的每个类都有 import 行。
5. = 是存入一个值。== 对数值和 char 提出问题。比较 String 要用 equals。
6. 字符串用双引号,单个 char 用单引号。
7. 不是 void 的方法,在每条路径上都要返回一个值。
8. 把 double 放进 int 需要强制转换。
9. 集合里放的是类:写 Integer,不写 int。
10. 局部变量在读取之前必须有值。
11. 实现接口方法的方法要标成 public。
12. 类名与文件名一致,main 要原样写出。

下面是最容易丢分的四组错误。每组里第一块是常见的错误写法,第二块是改正后的写法。

常见错误

错误写法
ArrayList<int> nums = new ArrayList<>();
nums.add(5);
int first = nums[0];

两处错误。尖括号里的类型必须是类,所以 int 要写成 Integer。而且 ArrayList 不是数组,不能用方括号,第一个元素是 nums.get(0)。

正确写法
ArrayList<Integer> nums = new ArrayList<>();
nums.add(5);
int first = nums.get(0);
错误写法
class Rect implements Shape {
    double area() {
        return 1.0;
    }
}

接口里的方法自动是 public。实现它的方法不能比它更封闭,所以也必须写 public。没写的话,编译器会报告说正在尝试分配更弱的访问权限。

正确写法
class Rect implements Shape {
    public double area() {
        return 1.0;
    }
}
错误写法
double x = 7 / 2.0;
int half = x;
String s = 'hi';

两处错误。Java 不会自己把 double 放进 int,因为小数部分会丢失;如果你确实想要这样,就写强制转换 (int) x。另外,单引号只用于单个 char,String 需要双引号。

正确写法
double x = 7 / 2.0;
int half = (int) x;
String s = "hi";
错误写法
static int sign(int x) {
    if (x > 0) {
        return 1;
    } else if (x < 0) {
        return -1;
    }
}

static int bump() {
    int n;
    n++;
    return n;
}

两处错误。在 sign 里,编译器看不出 x 可能等于 0,那种情况下什么都没有返回,所以它报告缺少 return 语句。在末尾加上 return。在 bump 里,n++ 读取 n 时它还没有值,所以 Java 报告变量可能尚未初始化。声明时就给它一个值。

正确写法
static int sign(int x) {
    if (x > 0) {
        return 1;
    } else if (x < 0) {
        return -1;
    }
    return 0;
}

static int bump() {
    int n = 0;
    n++;
    return n;
}

检查自己

  1. 改正所有的错误(共四处):string name = 'Leon'; if (name == "Leon") { System.out.println(name) }

    先自己做,再看答案

    1. 类型是 String,S 要大写。2. Leon 是 String,要用双引号:"Leon"。3. 比较文字要用 name.equals("Leon"),不能用 ==。4. System.out.println(name) 后面缺分号。正确写法:String name = "Leon"; if (name.equals("Leon")) { System.out.println(name); }

  2. 改正这个集合的写法:ArrayList<int> xs = new ArrayList<int>(); xs.add(1); System.out.println(xs.length);

    先自己做,再看答案

    尖括号里要用类,而且数量要用 size() 来取:ArrayList<Integer> xs = new ArrayList<>(); xs.add(1); System.out.println(xs.size()); 文件最上面还要写 import java.util.ArrayList;。

  3. 改正这个类:public class Dog { public void Dog(String name) { this.name = name; } }

    先自己做,再看答案

    有两处错误。构造方法不能写 void,应为 public Dog(String name)。而且类里没有字段 name,要在花括号里、构造方法上面加上 private String name;。

05

给自己打分

学完你能 你能对照评分标准诚实地给自己的手写答案打分,找出分数丢在哪里,安排明天的练习,并在限时下写代码。

在纸上,一页写满的答案看起来像是完成了,其实可能是错的。给自己打分能解决这个问题。你拿答案去对照评分标准。评分标准是一组条目,每条值若干分,每条描述一件你能在答案里看到的东西。要严格。真正算数的分数,是一个陌生人会给你的分数。

怎样逐条读评分标准。

1. 读一条。问自己:一个正确的答案里,我到底会看到什么?
2. 在你的答案里找到那样东西,用笔尖点住它。指得出来,就打勾并写下这条的分数;指不出来,这一条就是零分。
3. “差不多”不给半分。只有评分标准写明怎么拆分时,才给一条的部分分数。
4. 检查每一条路径。像“处理空列表”这样的条目,如果只有正常情况能工作,就是零分。
5. 把各条加起来,把总分写成“得分/满分”的形式放在页面上。

怎样找出分数丢在哪里。把每一个丢掉的分放进四个格子之一。思路:我不知道怎么解。记忆:我知道,但记不起语法或方法名。小错:在我本来会的东西上犯的小错误,例如漏了分号或写错了符号。时间:我没写完。数一数每个格子。最大的格子告诉你该练什么。思路:重读对应的课。记忆:多做“盖住再默写”。小错:用上一课的编译器检查清单,再加一个很小的测试。时间:练习先做计划。

第二天要做什么。第一,在空白页上不看资料,把同一道题的答案重写一遍。第二,做一道同类型的不同题目。第三,把两份答案再各打一次分。三天后和一周后再重复。当最大的格子换了,说明你在进步。

怎样在限时下写。

1. 先做计划。用掉最初的十分之一时间。写出方法头,再写三四行说明步骤的注释。不要跳过这一步。
2. 代码行之间空一行。你会需要空间补写漏掉的一行。
3. 变量名要清楚:用 count,不要用 c;用 longest,不要用 l。名字清楚,就不会把两个变量混在一起。
4. 用一个很小的输入手算测试。代码写完后,用最小的情况走一遍:只有一个元素、空列表、长度为 1 的单词。再走一个恰好卡在边界上的情况。这只要一分钟,却能找出大部分边界错误。
5. 大约每分花一分钟。某一部分卡住了,就写一行注释说明你打算怎么做,然后往下写,回头再补。
6. 留出两分钟做编译器检查。

在“练习”页面里,题型为“手写代码”的题目有手写模式:有计时器、参考答案,以及可以打勾的评分标准。完全按这一课的办法用它:先在纸上写,再打分。

新词

评分标准
一组条目,每条值若干分,每条描述一件你能在答案里指出来的东西。
边界情况
恰好落在边缘上的测试输入,例如长度恰好等于 min、只有一个元素或空列表。
编译器检查
把答案再读一遍,只检查形式:分号、括号、类型、import。

完整例子

Java 8+
import java.util.ArrayList;

public class Main {
    /* A student's answer. It has one bug. */
    static int countLong(ArrayList<String> words, int min) {
        int count = 0;
        for (String w : words) {
            if (w.length() > min) {
                count++;
            }
        }
        return count;
    }

    public static void main(String[] args) {
        ArrayList<String> words = new ArrayList<>();
        words.add("a");
        words.add("bb");
        words.add("cc");
        words.add("ddd");
        System.out.println(countLong(words, 2) + " (expected 3)");
    }
}
先预测输出,再看结果

预期输出

1 (expected 3)

逐步讲解

  1. 题目:写 static int countLong(ArrayList<String> words, int min),返回长度至少为 min 的单词有多少个。评分标准有四条,每条一分。(1) 方法头正确:static,返回 int,接收列表和数字。(2) 计数器从 0 开始。(3) 访问每个单词,并用 length() 读取长度。(4) 判断条件的意思是“至少 min”,并且返回计数器。

  2. 给第 1 条打分。static int countLong(ArrayList<String> words, int min) 是对的,用笔点住它。+1。

  3. 给第 2 条打分。int count = 0; 在那里。+1。

  4. 给第 3 条打分。for (String w : words) 访问每个单词,w.length() 读取长度。+1。

  5. 给第 4 条打分。条件是 w.length() > min,意思是“多于 min”,而不是“至少 min”。笔下找不到任何“至少”的东西。虽然返回了计数器,但这一条要求两样都做到。0 分。

  6. 得分:4 分里得 3 分。现在手算一个卡在边界上的小测试:min 是 2,而且有单词的长度恰好是 2。bb 和 cc 的长度是 2,没有被计入,所以方法返回 1 而不是 3。程序输出 1 (expected 3)。丢掉的那一分放进“小错”格:思路是对的,符号写错了。改法是 >=。

常见错误

错误写法
for (int i = 0; i <= words.size(); i++) {
    System.out.println(words.get(i));
}

条件 i <= words.size() 允许 i 等于 words.size(),但最后一个有效位置是 words.size() - 1。于是 words.get(i) 会让程序因 IndexOutOfBoundsException 而停止。用只有一个单词的小测试就能发现:size 是 1,而位置 1 不存在。

正确写法
for (int i = 0; i < words.size(); i++) {
    System.out.println(words.get(i));
}
错误写法
static int total(ArrayList<Integer> xs) {
    for (int x : xs) {
        int sum = 0;
        sum = sum + x;
    }
    return sum;
}

sum 是在循环体里声明的,所以每一轮都会重新创建,循环结束后就消失了。return 那一行看不到它,编译器会说找不到符号。要把计数器声明在循环之前。如果先写好计划(计数器、循环、返回),它就会放在正确的位置。

正确写法
static int total(ArrayList<Integer> xs) {
    int sum = 0;
    for (int x : xs) {
        sum = sum + x;
    }
    return sum;
}
错误写法
static void total(ArrayList<Integer> xs) {
    int sum = 0;
    for (int x : xs) {
        sum = sum + x;
    }
    System.out.println(sum);
}

题目说的是方法“返回”总和,这个方法却把它打印出来,什么也没有返回,调用者拿不到可用的值。这是读题的疏漏:写之前先在题目里找出“返回”“打印”“读取”这些词,并划上线。

正确写法
static int total(ArrayList<Integer> xs) {
    int sum = 0;
    for (int x : xs) {
        sum = sum + x;
    }
    return sum;
}

检查自己

  1. 评分标准里有一条“用 equals 比较 String”。你写的是 ==,而且你用两个字面量跟踪时它碰巧能工作。这一条得多少分?为什么?

    先自己做,再看答案

    0 分。这一条要求用 equals,笔下找不到它。== 比较的是箭头。它能工作只是因为文字相同的两个字面量是同一个对象;换成 new String("hi") 就会失败。

  2. 你有 12 分钟,完成一道 12 分的题:写一个带构造方法和一个方法的类,再写一个测试它的 main。写出带时间的计划。

    先自己做,再看答案

    1 分钟:把类头、字段和两个方法头先写成注释。4 分钟:写类。2 分钟:写带一个小测试的 main。3 分钟:用跟踪表手算这个小测试。2 分钟:做编译器检查。

  3. 写出你会用来手算测试“返回 ArrayList<String> 中最长单词的方法”的几个很小的输入。

    先自己做,再看答案

    空列表(先决定返回什么,再检查代码是否照做)、只有一个单词的列表、两个长度相同的单词(谁胜出?),以及最长的单词在最后的列表。把每个都用跟踪表走一遍。

接下来

在纸上练习:打开 INFO1113 的“手写代码”题,设好计时器,对照评分标准给自己打分。 →

独立整理的学习笔记,不是官方课程材料。