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

TypeScript类中的访问修饰符与封装原则详解

2021-11-115.7k 阅读

访问修饰符概述

在 TypeScript 类中,访问修饰符用于控制类的属性和方法的访问级别。通过合理使用访问修饰符,我们可以实现封装原则,将类的内部实现细节隐藏起来,只暴露必要的接口给外部使用,从而提高代码的安全性、可维护性和可扩展性。TypeScript 提供了三种主要的访问修饰符:publicprivateprotected

public 修饰符

  1. 定义与特点 public 是 TypeScript 中最宽松的访问修饰符。被 public 修饰的属性和方法可以在类的内部、子类以及类的实例外部被访问。如果在定义类的属性或方法时没有明确指定访问修饰符,TypeScript 会默认将其视为 public

  2. 代码示例

class Animal {
    public name: string;
    public constructor(name: string) {
        this.name = name;
    }
    public speak(): void {
        console.log(`${this.name} makes a sound.`);
    }
}

const dog = new Animal('Buddy');
console.log(dog.name); // 可以在实例外部访问public属性
dog.speak(); // 可以在实例外部调用public方法

在上述代码中,Animal 类的 name 属性和 speak 方法都被声明为 public,因此可以在创建 Animal 类的实例 dog 后,在外部直接访问 name 属性并调用 speak 方法。

private 修饰符

  1. 定义与特点 private 修饰符用于限制属性和方法只能在类的内部访问。被 private 修饰的成员对于类的外部以及子类(除非通过特定的访问器方法,后面会提到)来说是不可见的。这有效地隐藏了类的内部实现细节,符合封装的原则。

  2. 代码示例

class BankAccount {
    private balance: number;
    public constructor(initialBalance: number) {
        this.balance = initialBalance;
    }
    public deposit(amount: number): void {
        if (amount > 0) {
            this.balance += amount;
            console.log(`Deposited ${amount}. New balance: ${this.balance}`);
        } else {
            console.log('Invalid deposit amount.');
        }
    }
    public withdraw(amount: number): void {
        if (amount > 0 && amount <= this.balance) {
            this.balance -= amount;
            console.log(`Withdrew ${amount}. New balance: ${this.balance}`);
        } else {
            console.log('Insufficient funds or invalid withdrawal amount.');
        }
    }
}

const account = new BankAccount(1000);
// console.log(account.balance); // 这行代码会报错,因为balance是private属性
account.deposit(500);
account.withdraw(200);

在这个 BankAccount 类中,balance 属性被声明为 private。这意味着外部代码不能直接访问或修改 balance。只能通过类内部提供的 depositwithdraw 方法来间接操作 balance,这样可以确保对账户余额的操作符合业务逻辑,比如防止存入或取出负数金额以及防止透支等情况。

protected 修饰符

  1. 定义与特点 protected 修饰符介于 publicprivate 之间。被 protected 修饰的属性和方法可以在类的内部以及子类中访问,但不能在类的实例外部访问。它主要用于在继承体系中保护一些成员,使得子类可以访问这些成员,但外部代码无法直接访问。

  2. 代码示例

class Shape {
    protected color: string;
    public constructor(color: string) {
        this.color = color;
    }
    protected getColor(): string {
        return this.color;
    }
}

class Circle extends Shape {
    private radius: number;
    public constructor(color: string, radius: number) {
        super(color);
        this.radius = radius;
    }
    public display(): void {
        console.log(`Circle with color ${this.getColor()} and radius ${this.radius}`);
    }
}

const circle = new Circle('red', 5);
// console.log(circle.color); // 这行代码会报错,因为color是protected属性
// console.log(circle.getColor()); // 这行代码也会报错,因为getColor是protected方法
circle.display();

在上述代码中,Shape 类的 color 属性和 getColor 方法被声明为 protectedCircle 类继承自 Shape 类,在 Circle 类的 display 方法中可以访问 Shape 类的 protected 成员 colorgetColor。但在 Circle 类的实例 circle 外部,不能直接访问这些 protected 成员。

封装原则与访问修饰符的关系

  1. 封装的概念 封装是面向对象编程的重要原则之一,它将数据(属性)和操作这些数据的方法(行为)包装在一起,并隐藏对象的内部实现细节,只向外部提供公共的接口。这样可以使对象的使用者不需要了解对象的具体实现,只需要通过公共接口与对象进行交互,从而提高代码的安全性和可维护性。

  2. 访问修饰符对封装的实现

    • private 与封装private 修饰符是实现封装的最有力工具。通过将类的一些属性和方法声明为 private,可以确保这些内部细节不会被外部随意访问和修改,只有类内部的方法可以操作它们。这就像银行账户类中,账户余额的具体数值是敏感信息,通过 private 修饰符隐藏起来,外部只能通过存款和取款等公共方法来操作,保证了数据的安全性和一致性。
    • protected 与封装protected 修饰符在继承体系中对封装起到了补充作用。当一个类继承自另一个类时,protected 成员允许子类访问父类的部分内部细节,同时又阻止外部直接访问。比如在图形类的继承体系中,父类 Shape 的颜色属性可能对于子类 Circle 是有用的信息,但不应该被外部直接访问,这时 protected 修饰符就派上了用场。
    • public 与封装public 修饰符虽然允许外部访问,但它也是封装的一部分。通过将一些方法声明为 public,类向外部提供了接口,使得外部代码可以与类进行交互。例如,Animal 类的 speak 方法是 public,外部代码可以调用这个方法让动物发出声音,但不需要知道动物是如何实现发出声音这个行为的,这也符合封装的理念。

访问修饰符的实际应用场景

  1. 数据保护场景 在处理敏感数据时,如用户密码、银行账户信息等,使用 private 修饰符可以确保这些数据不会被外部非法访问和修改。以用户账户类为例:
class UserAccount {
    private password: string;
    public constructor(password: string) {
        this.password = password;
    }
    public changePassword(oldPassword: string, newPassword: string): void {
        if (oldPassword === this.password) {
            this.password = newPassword;
            console.log('Password changed successfully.');
        } else {
            console.log('Old password is incorrect.');
        }
    }
}

const user = new UserAccount('initialPassword');
// console.log(user.password); // 报错,password是private属性
user.changePassword('initialPassword', 'newPassword');

在这个 UserAccount 类中,password 属性是 private,外部无法直接获取或修改密码。只有通过 changePassword 这个公共方法,在验证旧密码正确的情况下才能修改密码,有效地保护了用户的密码信息。

  1. 继承体系中的代码复用与保护场景 在一个图形绘制库中,可能有一个基类 Graphic,包含一些通用的属性和方法,如颜色、位置等。子类 RectangleEllipse 继承自 Graphic,并根据自身特点实现具体的绘制方法。
class Graphic {
    protected color: string;
    protected x: number;
    protected y: number;
    public constructor(color: string, x: number, y: number) {
        this.color = color;
        this.x = x;
        this.y = y;
    }
    protected drawBase(): void {
        console.log(`Drawing graphic at (${this.x}, ${this.y}) with color ${this.color}`);
    }
}

class Rectangle extends Graphic {
    private width: number;
    private height: number;
    public constructor(color: string, x: number, y: number, width: number, height: number) {
        super(color, x, y);
        this.width = width;
        this.height = height;
    }
    public draw(): void {
        this.drawBase();
        console.log(`Drawing rectangle with width ${this.width} and height ${this.height}`);
    }
}

class Ellipse extends Graphic {
    private radiusX: number;
    private radiusY: number;
    public constructor(color: string, x: number, y: number, radiusX: number, radiusY: number) {
        super(color, x, y);
        this.radiusX = radiusX;
        this.radiusY = radiusY;
    }
    public draw(): void {
        this.drawBase();
        console.log(`Drawing ellipse with radius X ${this.radiusX} and radius Y ${this.radiusY}`);
    }
}

const rect = new Rectangle('blue', 10, 10, 50, 30);
const ellipse = new Ellipse('green', 20, 20, 25, 15);
rect.draw();
ellipse.draw();

在这个例子中,Graphic 类的 colorxy 属性以及 drawBase 方法都声明为 protected。子类 RectangleEllipse 可以访问这些 protected 成员,实现代码复用,同时外部代码无法直接访问这些内部细节,保护了图形类的内部状态和实现逻辑。

  1. 公共接口暴露场景 在开发一个组件库时,我们希望对外暴露一些公共的方法和属性,让开发者可以方便地使用组件的功能,同时隐藏组件的内部实现细节。例如,一个按钮组件类:
class Button {
    private isClicked: boolean;
    public text: string;
    public constructor(text: string) {
        this.text = text;
        this.isClicked = false;
    }
    public click(): void {
        this.isClicked = true;
        console.log(`Button '${this.text}' has been clicked.`);
    }
    public getClickStatus(): boolean {
        return this.isClicked;
    }
}

const myButton = new Button('Click me');
console.log(myButton.text);
myButton.click();
console.log(myButton.getClickStatus());

在这个 Button 类中,isClicked 属性是 private,表示按钮的点击状态是内部状态,不应该被外部直接修改。而 text 属性是 public,方便外部设置按钮的显示文本。clickgetClickStatus 方法也是 public,为外部提供了与按钮交互的接口,符合封装原则下公共接口暴露的场景。

访问修饰符的注意事项

  1. 类型兼容性与访问修饰符 在 TypeScript 中,类型兼容性只考虑类型结构,而不考虑访问修饰符。例如,一个只包含 public 成员的类与另一个包含相同 public 成员但还有 privateprotected 成员的类是兼容的。
class A {
    public prop: number;
    public method(): void {}
}

class B {
    public prop: number;
    public method(): void {}
    private privateProp: string;
}

let a: A = new A();
let b: B = new B();
a = b; // 这是允许的,尽管B有privateProp

虽然这种兼容性在某些情况下很方便,但也需要注意可能带来的潜在问题。比如,如果不小心将 B 类的实例赋值给 A 类的变量,可能会在后续使用中忽略 B 类的 privateprotected 成员的特殊逻辑。

  1. 使用访问器方法(getter 和 setter) 有时候,我们可能需要在外部访问 privateprotected 属性,但又要保持一定的控制。这时可以使用访问器方法(getter 和 setter)。
class Person {
    private age: number;
    public constructor(age: number) {
        this.age = age;
    }
    public getAge(): number {
        return this.age;
    }
    public setAge(newAge: number): void {
        if (newAge >= 0 && newAge <= 120) {
            this.age = newAge;
        } else {
            console.log('Invalid age value.');
        }
    }
}

const person = new Person(30);
console.log(person.getAge());
person.setAge(35);
console.log(person.getAge());

在这个 Person 类中,age 属性是 private,通过 getAgesetAge 方法,外部代码可以获取和修改 age 值,但在 setAge 方法中可以添加对年龄值的验证逻辑,保证数据的合法性。

  1. 访问修饰符与模块 在 TypeScript 模块中,默认情况下,模块内声明的变量、函数、类等都是私有的,只有通过 export 关键字导出后才能被其他模块访问。这与类中的访问修饰符概念类似,但作用范围不同。类中的访问修饰符控制类内部、子类以及实例外部的访问,而模块的导出控制模块之间的访问。
// moduleA.ts
class InternalClass {
    private data: string;
    public constructor(data: string) {
        this.data = data;
    }
}

export class ExportedClass {
    private internalInstance: InternalClass;
    public constructor(data: string) {
        this.internalInstance = new InternalClass(data);
    }
    public getInternalData(): string {
        return this.internalInstance.data;
    }
}

// main.ts
import { ExportedClass } from './moduleA';
const instance = new ExportedClass('Hello');
// console.log(instance.internalInstance); // 报错,internalInstance是private
console.log(instance.getInternalData());

在上述代码中,InternalClass 没有被导出,在 main.ts 中无法直接访问。ExportedClass 被导出,在 main.ts 中可以创建实例并访问其 public 方法,但不能访问其 private 成员 internalInstance

访问修饰符与 JavaScript 的对比

  1. JavaScript 的访问控制局限 JavaScript 本身并没有像 TypeScript 这样明确的访问修饰符。在 JavaScript 中,所有属性和方法默认都是公共的,可以在外部随意访问和修改。虽然可以通过一些闭包技巧来模拟私有属性和方法,但这种方式并不直观,也不够安全。
// JavaScript示例
function Person(name) {
    let privateName = name;
    this.getName = function() {
        return privateName;
    };
    this.setName = function(newName) {
        privateName = newName;
    };
}

const person = new Person('John');
console.log(person.getName());
person.setName('Jane');
console.log(person.getName());
// 虽然可以模拟私有属性,但仍有办法访问和修改
// 通过修改闭包内的变量等手段可以破坏封装

在这个 JavaScript 的 Person 构造函数中,通过闭包将 privateName 变量隐藏起来,通过 getNamesetName 方法来访问和修改它。但这种方式并不是真正的访问控制,外部代码仍然可以通过一些技巧绕过这些限制,比如使用 eval 等方式直接修改闭包内的变量。

  1. TypeScript 访问修饰符的优势 TypeScript 的访问修饰符为 JavaScript 带来了更严格的访问控制。它在编译阶段就可以检查访问权限,避免了在运行时才发现非法访问的问题。同时,明确的访问修饰符使代码结构更清晰,更容易理解哪些成员是可以公开访问的,哪些是内部实现细节。这对于大型项目的开发和维护非常有帮助,提高了代码的可维护性和安全性。

访问修饰符与设计模式

  1. 封装与单例模式 单例模式确保一个类只有一个实例,并提供一个全局访问点。在实现单例模式时,访问修饰符可以用来保证单例的唯一性和内部状态的安全性。
class Singleton {
    private static instance: Singleton;
    private data: string;
    private constructor(data: string) {
        this.data = data;
    }
    public static getInstance(data: string): Singleton {
        if (!Singleton.instance) {
            Singleton.instance = new Singleton(data);
        }
        return Singleton.instance;
    }
    public getData(): string {
        return this.data;
    }
}

const instance1 = Singleton.getInstance('Initial data');
const instance2 = Singleton.getInstance('Another data');
console.log(instance1 === instance2); // true
console.log(instance1.getData());

在这个单例模式的实现中,Singleton 类的构造函数是 private,防止外部直接创建实例。通过 getInstance 这个 public 静态方法来获取单例实例,保证了单例的唯一性。同时,data 属性是 private,外部只能通过 getData 方法来获取数据,保护了单例的内部状态。

  1. 封装与策略模式 策略模式定义一系列算法,将每个算法都封装起来,并且使它们可以相互替换。在实现策略模式时,访问修饰符可以用于隐藏具体策略类的内部实现细节,只暴露公共接口给客户端。
// 策略接口
interface SortStrategy {
    sort(arr: number[]): number[];
}

// 具体策略类
class BubbleSort implements SortStrategy {
    private compare(a: number, b: number): boolean {
        return a > b;
    }
    public sort(arr: number[]): number[] {
        for (let i = 0; i < arr.length - 1; i++) {
            for (let j = 0; j < arr.length - i - 1; j++) {
                if (this.compare(arr[j], arr[j + 1])) {
                    let temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                }
            }
        }
        return arr;
    }
}

class QuickSort implements SortStrategy {
    private partition(arr: number[], low: number, high: number): number {
        let pivot = arr[high];
        let i = low - 1;
        for (let j = low; j < high; j++) {
            if (arr[j] <= pivot) {
                i++;
                let temp = arr[i];
                arr[i] = arr[j];
                arr[j] = temp;
            }
        }
        let temp = arr[i + 1];
        arr[i + 1] = arr[high];
        arr[high] = temp;
        return i + 1;
    }
    private quickSortHelper(arr: number[], low: number, high: number): number[] {
        if (low < high) {
            let pi = this.partition(arr, low, high);
            this.quickSortHelper(arr, low, pi - 1);
            this.quickSortHelper(arr, pi + 1, high);
        }
        return arr;
    }
    public sort(arr: number[]): number[] {
        return this.quickSortHelper(arr, 0, arr.length - 1);
    }
}

// 上下文类
class Sorter {
    private strategy: SortStrategy;
    public constructor(strategy: SortStrategy) {
        this.strategy = strategy;
    }
    public sortArray(arr: number[]): number[] {
        return this.strategy.sort(arr);
    }
}

const bubbleSort = new BubbleSort();
const quickSort = new QuickSort();
const sorter1 = new Sorter(bubbleSort);
const sorter2 = new Sorter(quickSort);
const array = [3, 6, 8, 10, 1, 2, 1];
console.log(sorter1.sortArray(array));
console.log(sorter2.sortArray(array));

在这个策略模式的实现中,BubbleSortQuickSort 类的内部排序辅助方法(如 comparepartition 等)都声明为 private,隐藏了具体的排序实现细节。客户端只需要通过 sort 这个 public 方法来使用排序策略,符合策略模式中封装变化的思想,同时通过访问修饰符提高了代码的安全性和可维护性。

总结访问修饰符与封装的重要性

通过对 TypeScript 类中的访问修饰符(publicprivateprotected)以及它们与封装原则关系的详细探讨,我们可以看到访问修饰符在实现封装方面起着关键作用。封装不仅保护了类的内部数据和实现细节,还提高了代码的安全性、可维护性和可扩展性。在实际开发中,合理使用访问修饰符可以使代码结构更清晰,减少代码之间的耦合度,让不同模块之间的交互更加规范和安全。无论是开发小型应用还是大型项目,遵循封装原则并正确运用访问修饰符都是编写高质量代码的重要基础。同时,结合设计模式等编程思想,访问修饰符可以更好地服务于软件设计,实现更灵活、高效的系统架构。