Java语法中的类与对象:用示例理解面向对象基础

Java语法中的类与对象:用示例理解面向对象基础

Java语法中的类与对象:用示例理解面向对象基础

类与对象是 Java 面向对象编程的基础。初学时,容易把“类”理解成一组函数,或把“对象”当作普通变量的另一种叫法。更准确地说,类描述一类事物可以保存什么状态、能执行什么操作;对象则是按照这个描述创建出来的具体实例。

掌握类与对象,不只是为了通过语法练习。只要程序需要管理多个具有相似结构的数据,并让相关操作围绕这些数据组织起来,就可能用得上它们。例如,订单有编号、金额和状态,也需要计算应付金额、确认支付;把数据和操作放进同一个类型,通常比让一组彼此无关的变量和函数散落在程序各处更易维护。

本文使用 Java 基础语法,逐步介绍类、对象、字段、方法、构造器、封装、引用、静态成员和常见错误,并给出可运行示例。除特别说明外,代码适用于常见的现代 Java 版本;示例不依赖第三方库。

一、类与对象分别是什么

可以把类看作一种类型的定义。例如,下面的 Book 类描述一本书所需的部分信息:

class Book {
    String title;
    int pageCount;

    void printInfo() {
        System.out.println(title + "," + pageCount + " 页");
    }
}

这里,title 和 pageCount 是字段,用来表示对象的状态;printInfo 是方法,用来表示对象能够执行的行为。类定义本身并不代表某一本具体的书。

通过 new 可以创建对象:

Book firstBook = new Book();
firstBook.title = "Java 入门";
firstBook.pageCount = 320;
firstBook.printInfo();

firstBook 是一个引用变量,指向新创建的 Book 对象。对象的字段值可以和其他对象不同;两个对象即使属于同一个类,也不代表它们是同一个对象。

在 Java 中,类名通常采用大驼峰命名法,如 Book、BankAccount;字段和方法名通常采用小驼峰命名法,如 pageCount、printInfo。这属于常见代码约定,遵循它有助于团队阅读,不是编译器强制的命名规则。

二、字段、局部变量与默认值

字段属于对象或类,局部变量则在方法、构造器或代码块中声明。两者的初始化规则不同。

  • 实例字段属于某个对象。没有显式赋值时,会获得默认值:整数类型为 0,浮点类型为 0.0,布尔类型为 false,引用类型为 null。
  • 静态字段属于类,默认值规则与实例字段相同。
  • 局部变量没有可直接依赖的默认值。使用前必须先完成明确赋值,否则编译器会报错。
class Defaults {
    int count;          // 默认是 0
    boolean enabled;    // 默认是 false
    String name;        // 默认是 null

    void show() {
        int local;
        // System.out.println(local); // 编译错误:local 尚未初始化
        local = 1;
        System.out.println(local);
    }
}

字段默认值并不一定是合适的业务值。比如一个订单的总价默认为 0,无法说明它是尚未计算还是确实免费。因此,重要状态应由构造器或受控的方法明确初始化,而不是仅依赖语言提供的默认值。

三、构造器:创建对象时建立有效状态

构造器在创建对象时执行,名称必须与类名相同,而且没有返回类型,连 void 也不能写。构造器常用于接收必要数据,并确保新对象从一开始就处于可用状态。

class Book {
    private final String title;
    private final int pageCount;

    Book(String title, int pageCount) {
        this.title = title;
        this.pageCount = pageCount;
    }
}

this.title 指当前对象的字段,右侧的 title 是构造器参数。当参数名与字段名相同,this 可以消除歧义。final 字段只能在声明时或构造器中完成赋值,适合表达创建后不再更改的状态。

如果一个类没有声明任何构造器,编译器会提供一个无参数构造器。如果类声明了至少一个构造器,编译器就不会再自动补上无参数构造器。需要无参数构造器时,应显式声明。

class Note {
    Note(String text) {
        System.out.println(text);
    }
}

class Example {
    public static void main(String[] args) {
        Note note = new Note("已创建");
        // Note empty = new Note(); // 编译错误:没有无参数构造器
    }
}

构造器也可以重载,也就是在同一个类中定义多个参数列表不同的构造器。一个构造器可以通过 this(...) 调用本类的另一个构造器;这条调用必须出现在构造器的第一条语句位置。

class User {
    private final String name;
    private final int age;

    User(String name) {
        this(name, 0);
    }

    User(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

上例展示了语法,但它对年龄的业务含义没有作出约定。真实程序中,年龄为 0 是否代表未知,应该通过需求明确表达;不要为了方便而让一个普通数值同时承担“有效值”和“未提供”的含义。

四、方法:让操作靠近相关数据

方法由返回类型、名称、参数列表和方法体组成。方法可以接收参数、返回结果,也可以只执行操作并声明为 void。

class Rectangle {
    private final double width;
    private final double height;

    Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    double area() {
        return width * height;
    }

    boolean isSquare() {
        return width == height;
    }
}

调用方法时,实参必须与形参的类型和数量相匹配,或者能够按 Java 规则转换。方法的返回类型也必须与实际返回值相符。若方法声明返回 double,就不能在所有执行路径上都没有返回结果。

示例中的 width * height 适用于一般计算,但浮点数有精度特性,使用 == 判断两个 double 是否相等,在许多计算场景中并不可靠。若业务需要比较经过计算的浮点值,应根据精度要求设定容差;金额通常应使用 BigDecimal 或以最小货币单位表示的整数,并明确舍入规则。

五、访问控制与封装

字段默认没有访问修饰符时,只能在同一个包中访问;直接暴露字段会让其他代码绕过对象本身的规则,任意写入不合法状态。封装的做法通常是限制字段访问,再提供有意义的方法来读取或修改状态。

Java 常见访问控制如下:

  • private:仅当前类内部可访问。
  • 不写修饰符:同一包内可访问,常称为包级访问。
  • protected:同一包内可访问;其他包中的子类也可以按 Java 的访问规则访问。
  • public:可从可见范围内的其他代码访问。

下面的账户示例将余额设为私有,并通过存款、取款方法维护状态。金额单位为最小货币单位的整数;示例仅用于演示封装,不构成银行账户或支付系统的实现。

class SimpleAccount {
    private long balance;

    SimpleAccount(long initialBalance) {
        if (initialBalance < 0) {
            throw new IllegalArgumentException("初始余额不能为负数");
        }
        this.balance = initialBalance;
    }

    long getBalance() {
        return balance;
    }

    void deposit(long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("存款金额必须大于 0");
        }
        balance = Math.addExact(balance, amount);
    }

    void withdraw(long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("取款金额必须大于 0");
        }
        if (amount > balance) {
            throw new IllegalArgumentException("余额不足");
        }
        balance -= amount;
    }
}

Math.addExact 在整数加法溢出时会抛出 ArithmeticException,避免静默回绕。但这仍只是一个简化的内存对象:它没有处理并发访问、持久化、身份认证、授权、审计或交易一致性,不应被描述为可直接用于真实金融业务的安全账户。

封装也不等于“字段一律私有,再机械地为每个字段生成 getter 和 setter”。如果任意 setter 允许写入不合法状态,外部仍然能破坏对象约束。应根据业务需要设计操作,例如提供 withdraw,而不是提供一个可以直接把余额改成任意值的 setBalance。

六、参数限制与输入校验的边界

Java 语法会限制参数的数量、类型和调用形式,但一般不会自动知道业务允许的范围。例如,int 只能表示其类型范围内的整数,却不会替程序判断这个整数是否是合理的年龄、页数或数量。业务约束需要在合适的边界显式检查。

对外部输入的校验尤其重要。HTTP 请求、文件内容、命令行参数和数据库记录都不能仅因进入了一个 Java 方法,就被认为可信。校验方式应与字段语义一致:检查是否为空、长度、数值范围、枚举取值以及跨字段关系;对不满足约束的输入,明确拒绝或按需求处理。

例如,下面的构造器对标题和页数做了基本检查:

class ValidatedBook {
    private final String title;
    private final int pageCount;

    ValidatedBook(String title, int pageCount) {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("标题不能为空");
        }
        if (pageCount <= 0) {
            throw new IllegalArgumentException("页数必须大于 0");
        }
        this.title = title;
        this.pageCount = pageCount;
    }
}

isBlank 检查空字符串和只含空白字符的字符串,适用于现代 Java 版本;较旧的 Java 版本可能需要其他写法。这里没有规定标题最大长度,因为合适的上限取决于业务和存储设计。对任何字符串都声称存在某个统一、适用于所有应用的参数上限是不准确的:限制可能来自具体 API、协议、运行环境或业务规则,应查阅对应文档并在应用中明确。

输入校验可以帮助保持对象状态有效,也能降低部分风险,但不是完整的安全方案。对 SQL 数据,应使用参数化查询,而不是把用户输入拼接进 SQL 字符串;对 HTML 输出,应按输出上下文进行编码;路径、URL、文件名和命令参数也各自有不同的安全边界。仅靠一个“过滤特殊字符”的通用方法,不能可靠地解决这些问题。

七、对象引用、null 与参数传递

变量类型为类时,变量保存的是对象引用,而不是整份对象内容。对象引用也可能是 null,表示当前没有指向对象。对 null 调用实例方法或访问实例字段会触发 NullPointerException。

Book book = null;
// book.printInfo(); // 运行时会抛出 NullPointerException

引用赋值不会自动复制对象。两个变量可以指向同一个对象:

Book a = new Book();
Book b = a;

a.title = "共享对象";
System.out.println(b.title); // 输出:共享对象

方法参数采用值传递。对于对象类型,传入方法的是引用值的副本:方法可以通过这个副本访问并修改同一个对象,但在方法内把参数改指向另一个对象,不会让调用方变量改为指向新对象。

class Box {
    String value;
}

class ReferenceDemo {
    static void change(Box box) {
        box.value = "已修改";
        box = new Box();
        box.value = "新对象";
    }

    public static void main(String[] args) {
        Box original = new Box();
        original.value = "原值";

        change(original);
        System.out.println(original.value); // 输出:已修改
    }
}

第一处赋值修改了调用方所指对象的字段;第二处赋值只改变方法内部的局部参数 box 所保存的引用副本。理解这一点,有助于解释“方法可以改对象内容,却不能通过重新赋值替换调用方变量”的常见现象。

八、static:属于类而不是某个实例的成员

使用 static 声明的字段或方法属于类,不需要通过某个实例来表示。静态成员适用于不依赖特定对象状态的操作,或者确实由该类所有实例共享的数据。

class UserCounter {
    private static int count;

    UserCounter() {
        count++;
    }

    static int getCount() {
        return count;
    }
}

class CounterDemo {
    public static void main(String[] args) {
        new UserCounter();
        new UserCounter();
        System.out.println(UserCounter.getCount()); // 输出:2
    }
}

静态方法不能直接访问实例字段,因为调用它时不一定存在某个具体实例。应使用类名调用静态方法,例如 UserCounter.getCount()。在多线程程序中,普通静态整数的读写并不会因此自动具备线程安全性;若共享数据会被并发修改,需要按具体场景考虑同步、原子类或其他并发设计。

也不要把“静态”误解为“适合放所有全局状态”。全局可变数据会让对象之间产生隐式依赖,增加测试和并发维护难度。只有在生命周期、共享语义都明确时,才适合使用静态字段。

九、对象相等与 toString

== 用于基本类型时比较值;用于对象引用时比较是否指向同一个对象。它不一定表示两个对象的内容相同。

String first = new String("Java");
String second = new String("Java");

System.out.println(first == second);       // false:通常是不同对象
System.out.println(first.equals(second));  // true:内容相同

自定义类如果需要按照业务身份或字段内容比较,应根据需要重写 equals 和 hashCode,并遵守它们之间的约定:两个对象若按 equals 判定相等,其 hashCode 必须相同。将对象放入 HashSet 或作为 HashMap 的键时,这一点尤其重要。简单比较几个字段时,不能只重写其中一个方法而忽略另一个。

此外,可以重写 toString 提供适合调试的文本表示。但对象的文本输出可能进入日志,不应不加判断地包含密码、访问令牌、完整支付凭据或其他敏感信息。日志内容也要考虑隐私和访问控制。

十、可运行示例:把概念放在一起

下面的程序展示构造器、私有字段、方法、参数校验、异常处理和对象调用。将代码保存为 Main.java,然后使用常见 JDK 执行:

javac Main.java
java Main
class Product {
    private final String name;
    private final long priceInCents;
    private int stock;

    Product(String name, long priceInCents, int stock) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("商品名称不能为空");
        }
        if (priceInCents < 0) {
            throw new IllegalArgumentException("价格不能为负数");
        }
        if (stock < 0) {
            throw new IllegalArgumentException("库存不能为负数");
        }

        this.name = name;
        this.priceInCents = priceInCents;
        this.stock = stock;
    }

    String getName() {
        return name;
    }

    long getPriceInCents() {
        return priceInCents;
    }

    int getStock() {
        return stock;
    }

    void sell(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("销售数量必须大于 0");
        }
        if (quantity > stock) {
            throw new IllegalArgumentException("库存不足");
        }
        stock -= quantity;
    }

    @Override
    public String toString() {
        return name + ",价格:" + priceInCents
                + " 分,库存:" + stock;
    }
}

public class Main {
    public static void main(String[] args) {
        Product product = new Product("键盘", 25900, 10);

        System.out.println(product);
        product.sell(2);
        System.out.println("销售后:" + product);

        try {
            product.sell(20);
        } catch (IllegalArgumentException e) {
            System.out.println("操作失败:" + e.getMessage());
        }
    }
}

示例把价格用“分”表示,避免本例直接使用二进制浮点数保存货币金额;但实际交易系统还需要明确币种、舍入规则、税费计算、并发扣减、持久化和失败恢复等问题。这里的 sell 只修改当前进程中的对象,不会自动更新数据库,也不能保证多个线程同时销售时库存正确。

Product 的构造器检查名称、价格和库存的基本约束;sell 再检查每次操作的数量及当前库存。构造时校验与操作时校验解决的是不同问题:创建时确定初始状态合法,操作时保证状态转换符合规则。若对象还可以通过其他方法修改库存,这些方法也必须遵守一致的约束。

十一、类适用于什么场景,什么时候不必使用

当数据具有明确身份、需要持续维护状态,或相关行为需要围绕状态组织时,类通常很有用。常见例子包括订单、用户会话、文件读取器、网络连接、任务队列和领域模型。将状态与操作放在一起,能让调用者通过清晰的方法使用对象,而不必了解内部实现细节。

并非每段代码都需要创建对象。一次性的计算、无状态的辅助操作可以用静态方法;少量、短生命周期的数据也可以采用简单的数据结构。Java 的 record 可用于表达以数据为主的类型,但它主要提供不可变的组件字段和基于组件的常规方法,并不适合所有需要可变状态或复杂生命周期的对象。

判断是否应建类,可以先问:这些数据是否需要一起被创建、传递和维护?是否存在明确的状态约束?相关行为是否依赖这些数据?若回答大多为否,强行套用对象形式反而可能增加不必要的复杂度。

十二、初学者常见错误

  1. 把字段和局部变量混为一谈。字段有默认值,局部变量必须先明确赋值。依赖字段默认值可能让业务状态含义不清。
  2. 以为每个类都有无参数构造器。只有没有声明任何构造器时,编译器才提供默认无参数构造器;一旦声明了构造器,就不会自动补上它。
  3. 把类名、对象名和构造器搞混。构造器名必须与类名一致且没有返回类型;对象变量则是用来保存引用的普通变量。
  4. 对可能为 null 的引用直接调用方法。在调用前明确对象是否必须存在,并根据接口约定检查或处理 null,不要靠捕获 NullPointerException 代替正常输入校验。
  5. 用 == 判断对象内容相同。引用类型的 == 比较对象身份;内容比较应使用合适的 equals 实现。
  6. 以为参数赋值会替换调用方对象。Java 是值传递。对象参数传递的是引用值的副本,修改共享对象和重新给局部参数赋值是两回事。
  7. 公开所有字段,再任意读写。这会让对象无法保护自身约束。根据操作语义提供必要的方法,通常比开放任意 setter 更稳妥。
  8. 认为加上 private 就解决了安全问题。访问控制管理代码访问边界,不会自动完成认证、授权、加密、SQL 防注入或线程安全。
  9. 认为构造器校验一次就够了。如果对象之后还能变化,每个可能破坏约束的状态转换都要考虑校验。
  10. 在日志或 toString 中输出敏感数据。调试便利不代表可以忽略隐私和凭据保护。应限制日志字段并按安全要求处理。

结语

理解 Java 类与对象,可以从几个问题开始:哪些数据属于同一个对象?创建对象时哪些值是必需的?哪些状态必须保持有效?外部代码需要通过什么操作使用它?字段是否需要共享?对象比较的是身份还是内容?这些问题的答案会影响类的字段、构造器、方法和访问范围。

初学阶段不必追求复杂的继承层次或大量设计模式。先能清楚地区分类与对象、字段与局部变量、引用与对象本身,并通过封装和校验维护简单的不变量,就已经打下了面向对象编程的重要基础。随着程序规模增长,再根据真实变化和协作需求决定是否引入接口、继承、不可变对象或其他设计方式。

© 版权声明

相关文章