MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

深入理解Java中的封装与访问控制

2021-02-152.5k 阅读

Java 封装的概念与意义

在 Java 编程中,封装是面向对象编程的核心概念之一。它指的是将数据(成员变量)和操作这些数据的方法(成员方法)包装在一起,形成一个独立的单元,即类。通过封装,类可以隐藏其内部的实现细节,只向外部提供公共的接口来访问和操作数据。

数据隐藏

数据隐藏是封装的一个重要方面。将类的成员变量声明为 private,这意味着这些变量只能在类的内部被访问,外部代码无法直接访问或修改它们。例如,考虑一个简单的 Person 类:

public class Person {
    private String name;
    private int age;

    // 构造函数
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

在上述代码中,nameage 被声明为 private,外部代码无法直接访问它们。如果外部代码尝试这样做:

public class Main {
    public static void main(String[] args) {
        Person person = new Person("Alice", 30);
        // 下面这行代码会报错,因为name是private的
        System.out.println(person.name); 
    }
}

编译时会报错,提示无法访问 private 成员 name

提供访问方法

虽然数据被隐藏了,但为了让外部代码能够获取和修改这些数据,类通常会提供公共的访问方法。这些方法一般被称为 getter 和 setter 方法。对于 Person 类,可以添加如下的 getter 和 setter 方法:

public class Person {
    private String name;
    private int age;

    // 构造函数
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Getter方法获取name
    public String getName() {
        return name;
    }

    // Setter方法设置name
    public void setName(String name) {
        this.name = name;
    }

    // Getter方法获取age
    public int getAge() {
        return age;
    }

    // Setter方法设置age,这里可以添加一些逻辑验证
    public void setAge(int age) {
        if (age >= 0) {
            this.age = age;
        } else {
            System.out.println("年龄不能为负数");
        }
    }
}

现在外部代码可以通过这些公共方法来访问和修改 Person 对象的数据:

public class Main {
    public static void main(String[] args) {
        Person person = new Person("Alice", 30);
        System.out.println("姓名:" + person.getName());
        System.out.println("年龄:" + person.getAge());

        person.setAge(31);
        System.out.println("修改后的年龄:" + person.getAge());

        person.setAge(-5);
        System.out.println("尝试设置负数年龄后的年龄:" + person.getAge());
    }
}

在上述代码中,通过 getName()getAge() 方法获取数据,通过 setName()setAge() 方法修改数据。并且在 setAge() 方法中添加了简单的逻辑验证,确保年龄不能为负数。

封装的优点

  1. 数据保护:通过将数据声明为 private,只有类内部的方法可以访问和修改数据,防止外部代码对数据进行非法或不合理的操作,从而保护了数据的完整性和一致性。例如,在 Person 类中,通过 setAge() 方法的逻辑验证,避免了设置负数年龄的情况。
  2. 提高可维护性:如果类的内部实现发生变化,只要公共接口(getter 和 setter 方法)保持不变,外部代码就不需要进行修改。例如,如果 Person 类内部存储 age 的方式从 int 改为 Integer,只要 getAge()setAge() 方法的签名和功能不变,外部使用 Person 类的代码就不受影响。
  3. 实现细节隐藏:封装使得类的使用者不需要了解类的内部实现细节,只需要关注公共接口。这降低了代码的复杂性,提高了代码的可理解性和可复用性。例如,其他开发者在使用 Person 类时,只需要知道如何调用 getName()setName() 等方法,而不需要关心 name 是如何存储和管理的。

Java 中的访问控制修饰符

Java 提供了一系列访问控制修饰符,用于控制类、成员变量和成员方法的访问权限。这些修饰符决定了哪些代码可以访问特定的类、变量或方法。

public 修饰符

public 修饰符表示最高的访问权限。被 public 修饰的类、成员变量或成员方法可以在任何地方被访问,无论是在同一个包内还是不同的包中。

public 类

如果一个类被声明为 public,那么它可以被任何其他类访问,只要这些类可以被 JVM 找到。例如:

public class PublicClass {
    public String publicField;
    public void publicMethod() {
        System.out.println("这是一个public方法");
    }
}

在另一个类中可以这样访问 PublicClass

public class AnotherClass {
    public static void main(String[] args) {
        PublicClass publicObj = new PublicClass();
        publicObj.publicField = "Hello";
        publicObj.publicMethod();
    }
}

public 成员变量和方法

public 成员变量和方法可以被任何类访问,无论该类与包含这些成员的类是否在同一个包中。例如,在上述 PublicClass 中,publicFieldpublicMethod() 都可以被任何类访问。

private 修饰符

private 修饰符表示最低的访问权限。被 private 修饰的成员变量和成员方法只能在声明它们的类内部被访问。

private 成员变量

如前面 Person 类的例子中,nameage 被声明为 private,外部类无法直接访问它们。只有在 Person 类内部的方法(如 getName()setName() 等)可以访问和操作这些变量。

private 成员方法

private 成员方法通常用于类内部的辅助操作,外部类无法调用它们。例如:

public class MathUtils {
    private static int square(int num) {
        return num * num;
    }

    public static int calculateSquareSum(int a, int b) {
        int squareA = square(a);
        int squareB = square(b);
        return squareA + squareB;
    }
}

在上述 MathUtils 类中,square() 方法是 private 的,它用于计算一个数的平方,只在 calculateSquareSum() 方法内部被调用。外部类无法直接调用 square() 方法:

public class Main {
    public static void main(String[] args) {
        // 下面这行代码会报错,因为square()是private的
        int result = MathUtils.square(5); 
    }
}

protected 修饰符

protected 修饰符用于控制访问权限,使其介于 privatepublic 之间。被 protected 修饰的成员变量和成员方法可以在以下情况下被访问:

  1. 在同一个包内的任何类:与 package - private 类似。
  2. 在不同包中的子类:这是 protected 修饰符特有的功能。

protected 成员变量

例如,有一个 Animal 类:

package com.example.animals;

public class Animal {
    protected String name;

    public Animal(String name) {
        this.name = name;
    }
}

在同一个包内的 Dog 类可以直接访问 name

package com.example.animals;

public class Dog extends Animal {
    public Dog(String name) {
        super(name);
        System.out.println("狗的名字:" + name);
    }
}

在不同包中的子类,如 com.example.pets 包中的 PetDog 类也可以访问 name

package com.example.pets;

import com.example.animals.Animal;

public class PetDog extends Animal {
    public PetDog(String name) {
        super(name);
        System.out.println("宠物狗的名字:" + name);
    }
}

protected 成员方法

同样以 Animal 类为例,添加一个 protected 方法:

package com.example.animals;

public class Animal {
    protected String name;

    public Animal(String name) {
        this.name = name;
    }

    protected void makeSound() {
        System.out.println("动物发出声音");
    }
}

在同一个包内的 Dog 类可以重写这个方法:

package com.example.animals;

public class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    @Override
    protected void makeSound() {
        System.out.println("汪汪汪");
    }
}

在不同包中的子类 PetDog 类也可以重写这个方法:

package com.example.pets;

import com.example.animals.Animal;

public class PetDog extends Animal {
    public PetDog(String name) {
        super(name);
    }

    @Override
    protected void makeSound() {
        System.out.println("宠物狗叫:呜呜呜");
    }
}

默认(package - private)访问权限

当一个类、成员变量或成员方法没有使用任何访问控制修饰符时,它具有默认的访问权限,也称为 package - private。具有 package - private 访问权限的元素只能在同一个包内的类中被访问。

package - private 类

例如,创建一个没有修饰符的 InternalClass

class InternalClass {
    int packagePrivateField;

    void packagePrivateMethod() {
        System.out.println("这是一个package - private方法");
    }
}

在同一个包内的其他类可以访问 InternalClass 及其成员:

public class Main {
    public static void main(String[] args) {
        InternalClass internalObj = new InternalClass();
        internalObj.packagePrivateField = 10;
        internalObj.packagePrivateMethod();
    }
}

但是,如果在不同包中的类尝试访问 InternalClass,会导致编译错误:

package anotherpackage;

// 下面这行代码会报错,因为InternalClass是package - private的
import com.example.InternalClass; 

public class AnotherClass {
    public static void main(String[] args) {
        // 同样会报错
        InternalClass internalObj = new InternalClass(); 
    }
}

package - private 成员变量和方法

对于类中的成员变量和方法,如果没有修饰符,它们同样具有 package - private 访问权限。在同一个包内的类可以访问这些成员,而不同包内的类则不能访问。

访问控制与封装的结合

访问控制修饰符是实现封装的重要手段。通过合理使用这些修饰符,可以有效地隐藏类的内部实现细节,同时提供安全、可控的外部接口。

合理使用访问控制修饰符实现封装

  1. 将成员变量声明为 private:这是封装的基本操作,确保数据的安全性,防止外部代码直接访问和修改。例如,在 BankAccount 类中:
public class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        if (initialBalance >= 0) {
            this.balance = initialBalance;
        } else {
            System.out.println("初始余额不能为负数");
        }
    }

    public double getBalance() {
        return balance;
    }

    public void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        } else {
            System.out.println("存款金额必须大于0");
        }
    }

    public void withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
        } else {
            System.out.println("取款金额无效或余额不足");
        }
    }
}

在上述 BankAccount 类中,balance 被声明为 private,外部代码只能通过 getBalance()deposit()withdraw() 方法来访问和操作余额,保证了余额数据的安全性和一致性。 2. 将内部辅助方法声明为 private:如果类中有一些方法只用于内部的辅助操作,不应该被外部调用,那么将这些方法声明为 private。例如,在 StringUtil 类中:

public class StringUtil {
    private static boolean isVowel(char c) {
        c = Character.toLowerCase(c);
        return c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u';
    }

    public static int countVowels(String str) {
        int count = 0;
        for (char c : str.toCharArray()) {
            if (isVowel(c)) {
                count++;
            }
        }
        return count;
    }
}

在上述 StringUtil 类中,isVowel() 方法是内部辅助方法,用于判断一个字符是否是元音字母,被声明为 private,外部代码无法直接调用它,只能通过 countVowels() 方法间接使用其功能。 3. 根据需要使用 protected 和 package - private:如果希望某些成员在同一个包内或子类中可访问,可以使用 protectedpackage - private。例如,在一个图形绘制库中,有一个 Shape 类:

package com.example.graphics;

public class Shape {
    protected int x;
    protected int y;

    public Shape(int x, int y) {
        this.x = x;
        this.y = y;
    }

    protected void draw() {
        System.out.println("绘制形状在(" + x + ", " + y + ")");
    }
}

然后有 Circle 类继承自 Shape

package com.example.graphics;

public class Circle extends Shape {
    private int radius;

    public Circle(int x, int y, int radius) {
        super(x, y);
        this.radius = radius;
    }

    @Override
    protected void draw() {
        System.out.println("绘制圆形在(" + x + ", " + y + "),半径为" + radius);
    }
}

在这个例子中,Shape 类的 xydraw() 方法被声明为 protected,这样 Circle 类(子类)可以访问和重写这些成员,同时在同一个包内的其他类也可以根据需要访问这些成员。

访问控制在继承中的应用

在继承关系中,访问控制修饰符起着重要的作用。子类继承父类的成员,但是访问权限会受到父类中访问控制修饰符的限制。

  1. public 成员:子类继承父类的 public 成员后,这些成员在子类中仍然是 public 的,可以被任何类访问。例如:
public class Parent {
    public void publicMethod() {
        System.out.println("父类的public方法");
    }
}

public class Child extends Parent {
    // 继承的publicMethod()在子类中仍然是public的
}
  1. protected 成员:子类继承父类的 protected 成员后,这些成员在子类中仍然是 protected 的,可以在同一个包内的类以及不同包中的子类中访问。例如:
package com.example.parentpackage;

public class Parent {
    protected void protectedMethod() {
        System.out.println("父类的protected方法");
    }
}

package com.example.childpackage;

import com.example.parentpackage.Parent;

public class Child extends Parent {
    @Override
    protected void protectedMethod() {
        System.out.println("子类重写的protected方法");
    }
}
  1. private 成员:子类不能继承父类的 private 成员,这些成员对子类是不可见的。例如:
public class Parent {
    private void privateMethod() {
        System.out.println("父类的private方法");
    }
}

public class Child extends Parent {
    // 无法访问父类的privateMethod()
    public void test() {
        // 下面这行代码会报错
        privateMethod(); 
    }
}
  1. package - private 成员:如果子类与父类在同一个包中,子类可以继承父类的 package - private 成员,并且这些成员在子类中仍然具有 package - private 访问权限。如果子类与父类在不同包中,子类无法继承父类的 package - private 成员。例如:
package com.example.samepackage;

class Parent {
    void packagePrivateMethod() {
        System.out.println("父类的package - private方法");
    }
}

class Child extends Parent {
    // 可以继承package - privateMethod(),且在同一个包内可访问
}

而在不同包的情况下:

package com.example.differentpackage;

import com.example.samepackage.Parent;

public class Child extends Parent {
    // 无法继承package - privateMethod(),编译会报错
    public void test() {
        packagePrivateMethod(); 
    }
}

最佳实践与常见问题

封装的最佳实践

  1. 最小化可访问性:尽量将成员变量声明为 private,只提供必要的公共访问方法。这样可以最大程度地保护数据,避免外部代码对数据进行不合理的操作。例如,在 User 类中,如果密码字段只需要在内部验证时使用,就应该声明为 private,并且不提供 setter 方法,只提供用于验证密码的方法。
public class User {
    private String username;
    private String password;

    public User(String username, String password) {
        this.username = username;
        this.password = password;
    }

    public String getUsername() {
        return username;
    }

    public boolean validatePassword(String inputPassword) {
        return password.equals(inputPassword);
    }
}
  1. 提供清晰的接口:公共方法应该具有清晰的命名和明确的功能,让使用者能够容易理解如何使用。例如,在 FileManager 类中,readFile()writeFile() 等方法的命名就很直观,使用者可以很容易知道这些方法的作用。
public class FileManager {
    public String readFile(String filePath) {
        // 读取文件的逻辑
        return "文件内容";
    }

    public void writeFile(String filePath, String content) {
        // 写入文件的逻辑
    }
}
  1. 在 setter 方法中进行数据验证:如前面 Person 类的 setAge() 方法一样,在设置数据的方法中添加必要的数据验证逻辑,确保数据的合法性。例如,在 EmailValidator 类中,setEmail() 方法可以验证输入的邮箱格式是否正确。
public class EmailValidator {
    private String email;

    public void setEmail(String email) {
        if (email.matches("^[A - Za - z0 - 9+_.-]+@[A - Za - z0 - 9.-]+$")) {
            this.email = email;
        } else {
            System.out.println("邮箱格式不正确");
        }
    }

    public String getEmail() {
        return email;
    }
}

访问控制的常见问题

  1. 过度暴露成员:如果将成员变量或方法声明为 public 而不是 privateprotected,可能会导致外部代码对类的内部实现进行不必要的访问和修改,破坏封装性。例如,在 DatabaseConnection 类中,如果将数据库连接字符串声明为 public,外部代码可能会随意修改连接字符串,导致数据库连接异常。
public class DatabaseConnection {
    // 不应该将连接字符串声明为public
    public String connectionString; 

    public DatabaseConnection(String connectionString) {
        this.connectionString = connectionString;
    }

    public void connect() {
        // 使用connectionString进行连接的逻辑
    }
}

正确的做法是将 connectionString 声明为 private,并提供 getter 方法(如果有必要),同时在 connect() 方法中使用这个 private 变量。

public class DatabaseConnection {
    private String connectionString;

    public DatabaseConnection(String connectionString) {
        this.connectionString = connectionString;
    }

    public void connect() {
        // 使用connectionString进行连接的逻辑
    }

    public String getConnectionString() {
        return connectionString;
    }
}
  1. 继承中的访问权限问题:在继承时,需要注意父类成员的访问权限对继承的影响。如果在子类中尝试访问父类的 private 成员,会导致编译错误。另外,在重写父类的方法时,子类方法的访问权限不能比父类方法的访问权限更严格。例如:
public class Parent {
    protected void protectedMethod() {
        System.out.println("父类的protected方法");
    }
}

public class Child extends Parent {
    // 下面这行代码会报错,子类方法访问权限不能比父类更严格
    private void protectedMethod() { 
        System.out.println("子类重写的方法");
    }
}

正确的做法是保持子类方法的访问权限与父类方法相同或更宽松,如将 private 改为 protectedpublic。 3. 包访问权限的混淆:对于 package - private 访问权限,开发者可能会混淆其作用范围。记住,package - private 元素只能在同一个包内访问,不同包中的类无法访问,即使它们之间存在继承关系(除非父类成员是 protected)。例如,在开发一个库时,如果不小心将一些内部使用的类或方法声明为 package - private,而又希望在其他包中的子类中使用,就会出现问题。此时,可能需要将这些成员改为 protected 或者重新设计包结构。

通过深入理解 Java 中的封装与访问控制,开发者可以编写出更安全、可维护和可复用的代码,这是 Java 面向对象编程的重要基础。在实际项目中,合理运用封装和访问控制机制,能够提高代码的质量和整体架构的健壮性。