第 7 章 · 面向对象(上)

我刚学 Java 时把类当「带方法的结构体」用:字段全 public、哪里要哪里改,写了两周,然后花了一晚上修一个「年龄被改成 -5 导致统计全错」的 bug,才第一次真正理解封装是什么。本章从那次翻车讲起:类与对象、构造方法、this、封装的真实收益,再把 static 和内部类收尾。这是面向对象的第一章,只讲「怎么把数据和方法捆好」,继承与多态留给下一章。

7.1 类与对象:模板和实例

class Person {
    String name;          // 字段(成员变量)
    int age;

    void sayHello() {     // 方法
        System.out.println("Hi, I'm " + name);
    }
}

Person p = new Person();  // new:堆上分配,返回引用
p.name = "Tom";
p.sayHello();             // 调用,输出 Hi, I'm Tom

类是模板,对象是按模板造出的实例,new 在堆上开地、返回门牌(第 6 章讲过的引用)。方法内部的 name 默认就是「当前对象的 name」,这个隐式传递的「当前对象」,就是 this,7.4 节正式介绍。

7.2 封装:private 的真实收益

翻车事故的完整版:age 是 public 的,散落在十几个文件里的赋值语句,有一处写了 p.age = input - userAge(本意算差值),负年龄混进统计。修复方案不是「把那处改对」(下个月还会再错),而是把修改权收归一处:

class Person {
    private int age;                                   // 外界再也摸不到

    public int getAge() { return age; }                // 只读门
    public void setAge(int age) {
        if (age < 0 || age > 150)
            throw new IllegalArgumentException("非法年龄: " + age);
        this.age = age;
    }
}

封装的收益清单:约束集中(setter 里校验一次,全体调用者受益)、内部可换(以后 age 改成 Date 生日字段,外界接口不变)、可读性自文档(getter/setter 的存在说明「这些字段有故事」)。Java 的惯例命名是 getXxx/setXxx,IDE 一键生成,别手敲。看到这里你可能会问「getter/setter 不就是多此一举吗」,这个质疑是对的如果校验和演变都不需要;工程里它们几乎总需要,所以惯例成立。

7.3 构造方法:对象出生时的体检

class Person {
    private String name;
    private int age;

    public Person(String name, int age) {   // 与类同名、无返回类型
        this.name = name;                   // this.name 是字段,age 是参数
        setAge(age);                        // 复用 setter 的校验
    }
}

构造方法在 new 时自动调用,负责让对象「出生即合法」。三个规则:不写构造时编译器送一个无参版(字段取默认值);一旦手写了有参构造,默认无参版立刻消失(new Person() 编译不过,想都要就两个都写);构造间可以互相委托(this(...) 必须是构造第一句)。「出生即合法」是面向对象工程化的重要一步:非法对象根本没机会存在,比「构造完再补校验」可靠得多。

7.4 this:那个隐形的参数

this 指当前对象。三个用途按频率排:区分同名(this.age = age,字段 vs 参数)、传自己(other.compare(this))、构造委托(this(name, age))。它的机器级真相值得一提(计组课第 12 章的 C 结构体函数指针成员拿它对照):调用 p.sayHello() 时,编译器把 p 作为隐藏第一个参数塞进去,C++/Python 的 self 同理,只是 Java 帮你藏了。第 12 章(方法表)会看到这个「隐藏参数」的多态版本。

7.5 GC:内存的自动管家

对象不再被任何引用指向,就成了垃圾,由 JVM 的 GC(垃圾回收)自动回收。和 C 的手动 malloc/free(指针课第 9 章)对比:

  • 不再有泄漏(谁都不引用就回收)和悬空指针(引用保证要么 null 要么有效,计组课第 14 章的案卷整页销案)。
  • 代价:回收时机不可控,GC 运行时有暂停(STW);内存占用水位高(攒着批量回收)。
  • System.gc() 只是「建议」,不保证立刻执行。

内存泄漏在 Java 里还有吗?有,但形态变了:不是「忘了 free」,而是该忘的忘不掉(静态集合无限囤对象、监听器没注销、长命对象攥着短命对象的引用),排查工具从 valgrind 换成堆 dump + MAT 分析。这个话题第 10 章(集合类)和第 14 章(多线程)会再回来。

7.6 static:属于类的成员

class Counter {
    static int count = 0;              // 类级:全类一份
    int id;                            // 实例级:每对象一份

    static int next() { return ++count; }   // 静态方法:类名直调
}
Counter.next();                        // 不 new 也能调

static 成员放在类上而非对象上,所有实例共享。规则:静态方法不能访问实例成员(它没有 this!没有当前对象,自然摸不到实例字段),实例方法可以访问静态成员。典型用途:常量(static final)、工具方法(Math.max,第 6 章埋的伏笔)、计数器、以及单例模式(私有构造 + 静态方法返回唯一实例,全局只需要一个的东西:配置、连接池)。反模式预警:static 可变状态是并发 bug 温床(第 14 章),静态集合当缓存要管住容量。

7.7 内部类:类中有类

类可以定义在另一个类的体内,按位置分三种:成员内部类(持有外部类引用,能摸外部私有成员,事件处理器常用)、静态内部类(不依赖外部实例,Builder 模式标配)、匿名内部类(当场 new 接口实现,第 11 章 GUI 的事件回调主角,Lambda 出现前 Java 回调的唯一姿势)。初学阶段记住「匿名内部类 = 一次性的接口实现」即可,第 11 章反复用到。

常见错误

  • 忘了写 this 导致参数赋值给自己。this.age = age 写成 age = age,编译器只当你在给参数自己赋值,字段纹丝不动,对象「没初始化」还不报错(IDEA 会给灰色提示,认真看警告)。
  • public 字段裸奔。7.2 的翻车原案;字段一律 private,需要就 getter/setter。
  • 静态方法里用 this 或实例字段。编译错误「non-static variable cannot be referenced from a static context」,main 方法里(new 之前)想调实例方法都会撞上;先 new,再调。
  • 以为 System.gc() 能手动清内存。它只是建议;真要管内存,从「不再持有引用」下手。

练习

  1. 实现:BankAccount 类(private 余额,deposit/withdraw 带校验(余额不足抛异常),getBalance),写 main 测试正反用例,体验「非法状态进不来」。
  2. 动手:验证构造方法规则:只写有参构造,试 new Person(),抄录编译错误;补无参构造后修复。
  3. 动手:验证 static:Counter 里加实例字段,在静态方法里访问它,抄录编译错误并解释(没有 this,哪来的实例字段)。
  4. 思考:对比 C 结构体+函数指针(计组课第 12 章)和 Java 类:封装(private)、构造、GC 各自由谁保证?C 里「出生即合法」要靠什么纪律实现?