[TypeScript] 類別

類別方法

例:

class Greeter {
  greet(name: string) {
    console.log(name + ", hi!");
  }
}

new Greeter().greet("tt"); // 正確

new Greeter().greet(); // 報錯:應有1個引數。

建構函式的例子:

class Greeted {
  constructor(message: string) {
    console.log("Hi, " + message + "!");
  }
}

new Greeted("abc"); // 正確
 
new Greeted(); // 報錯:應有1個引數

類別屬性

類別屬性使用與介面宣告的語法相同:在屬性名稱後面緊接著選擇的型別註記。

TypeScript 不會從建構函式中,推斷類別中可能存在的成員。

例:

class FieldTrip {
  // 類別屬性:明確宣告 destination 為 string
  destination: string;

  constructor(destination: string) {
    this.destination = destination; // 正確
    
    // 報錯:沒有 abc 這個屬性
    this.abc = destination;
  }
}

const trip = new FieldTrip("plane");
trip.destination; // 正確

trip.abc; // 報錯

函式屬性

先瞭解有 JavaScript 當中的類別函式,有兩種寫法(方法和屬性)。

語法一:

class WithMethod {
  myMethod(){
    console.log("abc");
  }
}

// 判斷結果是 true,所有的類別實體都使用相同的函式定義。
new WithMethod().myMethod === new WithMethod().myMethod;

語法二:

class WithProperty {
  myProperty: () => {
    console.log("abc");
  }
}

// 判斷結果是 false,這將會為每個類別實體建立一個新函式
new WithProperty().myProperty === new WithProperty().myProperty;

可以使用與類別方法和獨立函式相同的語法:

class abcde {
  takesParameters = (input: boolean) => input ? "Yes" : "No";
}

const instance = new abcde();
instance.takesParameters(true); // 正確

// 報錯:型別 number 的引數,不可指派給型別 boolean 的參數
instance.takesParameters(123);

初始化檢查

開啟嚴格(建議開啟 strictPropertyInitialization)的編譯器設定的話,可以讓 TypeScript 檢查建構函式中,每個屬性宣告的型別,如下情況:

class WithValue {
  immediate = 0; // 正確
  later: number; // 正確(因為有在constructor中設定初始值)
  mayBeundefined: number | undefined; // 正確(允許 undefined)
  
  // 報錯:屬性 unused 沒有設定初始值,在 constructor 中也沒有明確給定資料
  unused: number;

  constructor() {
    this.later = 1;
  }
}

明確指定的屬性

如果確保一個屬性不需要套用嚴格的初始化檢查的話,可以在屬性名稱之後加一個驚嘆號,則可關閉檢查。

這樣做會向 TypeScript 斷言,該屬性將在第一次使用之前,會被指派一個非 undefined 的資料。

例(避免使用,建議應用 constructor):

class ActivitiesQueue {
  pending!: string[]; // 正確,若沒有加驚嘆號,就會報錯。

  initialize(pending: string[]) {
    this.pending = pending;
  }

  next() {
    return this.pending.pop();
  }
}

const abc = new ActivitiesQueue();
abc.initialize(["a", "b", "c"]);
abc.next();

可選擇的屬性

可以在類別屬性名稱之後,加上問號,來代表該屬性是可選擇的:

class MissingInitializer {
  property?: string;
}

// 正確
let a = new MissingInitializer().property?.length;
console.log(a); // 會印出 undefined

// 報錯:Object is possibly 'undefined'.
new MissingInitializer().property.length;

唯讀屬性

類別可以在宣告名稱之前加上 readonly 關鍵字來將屬性宣告成唯讀,宣告為 readonly 的屬性,只能在宣告它們的地方或在建構函式中,指派初始資料。例:

class Quote {
  readonly text: string;

  constructor(text: string) {
    // 在建構函式中,設定初始資料,是可以的。
    this.text = "";
  }

  emphasize() {
    // 報錯:因為 text 是唯讀屬性。
    this.text += "a";
  }
}
const quote = new Quote("aaa");
// 以下這行報錯,因為 text 是唯讀屬性,無法設定資料。
Quote.text = "hihi";

另外一種情況,宣告為具有基本初始資料的唯讀屬性與其它屬性相比,它們可能被推斷為經過資料窄化的字面型別。例:

class RandomQuote {
  readonly a: string = "aaa";
  readonly b = "bbb";

  constructor() {
    if(Math.random() > 0.5){
      this.a = "aaa change"; // 正確

      // 以下這行報錯:型別 "bbb change" 不可指派給型別 "bbb"
      this.b = "bbb change";
    }
  }
}
const quote = new RandomQuote();
quote.a; // 型別:string
quote.b; // 型別:"bbb"

作為型別的類別

下例中,有一個 teacher 變數,型別為 Teacher 類別:

class Teacher {
  sayHello() {
    console.log("Hello");
  }
}

let teacher: Teacher;
teacher = new Teacher(); // 正確

// 報錯:型別 string 不可指派給型別 Teacher。
teacher = "wow";

另一個例子:withSchoolBus 接受一個 SchoolBus 型別的參數。透過任何具有 () => string[] 型別的 getAbilities 屬性來達成,例:

class SchoolBus {
  getAbilities() {
    return ["a", "b"];
  }
}

// 參數要求類別型別,較少見
function withSchoolBus(bus: SchoolBus) {
  console.log(bus.getAbilities());
}

withSchoolBus(new SchoolBus()); // 正確

// 正確
withSchoolBus({
  getAbilities: () => ["c"]
});

// 以下會報錯:型別 number 不可指派給型別 string[]
withSchoolBus({
  getAbilities: () => 123
});

類別和介面

TypeScript 允許在類別名稱之後加上 implements 關鍵字,後面緊接著所遵循的介面,代表介面中的每一個部分都要被實作。

例:

interface Learner {
  name: string;
  study(hours: number): void;
}

class Student implements Learner {
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  study(hours: number) {
    console.log("abc");
  }
}

class Slacker implements Learner {
  // 報錯:類別 Slacker 不正確地實作介面 Learner。
  // 型別 Slacker 缺少屬性 study。
  name = "Rocky";
}

實作介面只是一種安全檢查。它不會將介面當中的任何成員,複製到類別中。

多個實作的介面

在 TypeScript 中,允許將類別宣告為實作多個介面,以逗號區隔。

例:

interface Graded {
  grades: number[];
}

interface Reporter {
  report: () => string;
}

class ReportCard implements Graded, Reporter {
  grades: number[];

  constructor(grades: number[]) {
    this.grades = grades;
  }

  report() {
    return this.grades.join(", ");
  }
}

// 報錯:類別 Empty 不正確地實作介面 Graded。
//   型別 Empty 缺少屬性 grades。
// 報錯:類別 Empty 不正確地實作介面 Reporter。
//   型別 Empty 缺少屬性 report。
class Empty implements Graded, Reporter {

}

在開發過程中,可能有某些介面的定義,會使得某一個類別不可能同時實作這兩者,將導致該類別至少出現一個型別錯誤,所以需留意。

擴充(繼承)類別

基本類別(base class)上宣告的任何方法或屬性,都可在子類別(也稱做衍生類別)使用。

例:

class Teacher {
  teach() {
    console.log("teach");
  }
}

// Teacher 是基本類別(base class),StudentTeacher 是衍生類別或稱做子類別
class StudentTeacher extends Teacher {
  learn() {
    console.log("learn");
  }
}

const teacher = new StudentTeacher();
teacher.teach(); // 正確(在 base class 有定義)
teacher.learn(); // 正確(在 subclass 有定義)

// 報錯:型別 StudentTeacher 沒有 other 屬性
teacher.other();

指派性的擴充

例:

class Lesson {
  subject: string;

  constructor(subject: string) {
    this.subject = subject;
  }
}

class OnlineLesson extends Lesson {
  url: string;

  constructor(subject: string, url: string) {
    super(subject); // 會執行基本類別(父類別)
    this.url = url;
  }
}

let lesson: Lesson;
lesson = new Lesson("coding"); // 正確
lesson = new OnlineLesson("coding", "abc"); // 正確

let online: OnlineLesson;
online = new OnlineLesson("coding", "def"); // 正確

// 報錯:型別 Lesson 缺少 url 屬性,但型別 OnlineLesson 必須有 url 屬性
online = new Lesson("coding");

覆寫建構函式

與原生 JavaScript 一樣,TypeScript 不需要子類別來定義自己的建構函式。沒有自己子類別的建構函式,會隱函使用基本類別(父類別)的建構函式。

在 JavaScript 中,如果子類別確實宣告自己的建構函式,這麼必須透過 super 關鍵字呼叫其基本類別的建構函式

例:

class GradeAnnouncer {
  message: string;

  constructor(grade: number) {
    this.message = grade >= 65 ? "a" : "b";
  }
}

class PassingAnnouncer extends GradeAnnouncer {
  constructor() {
    super(100);
  }
}

class FailingAnnouncer extends GradeAnnouncer {
  // 報錯:衍生類別的建構函式必須執行 super 函式
  constructor() {
    
  }
}

根據 JavaScript 規則,子類別的建構函式必須在存取 this 或 super 之前,呼叫基本建構函式。如果 TypeScript 在 super() 之前看到 this 或 super 被存取,系統將報告型別錯誤。例:

class GradesTally {
  grades: number[] = [];

  aaa(...grades: number[]) {
    this.grades.push(...grades);
    return this.grades.length;
  }
}

class ContinuedGradesTally extends GradesTally {

  constructor(previousGrades: number[]) {
    this.grades = [...previousGrades];
    // 上面這行報錯:因為必須先呼叫 super,才能存取衍生類別中建構函式的 this

    super();
  }

}

覆寫方法

子類別可以重新宣告與基本類別相同名稱的新方法。只要子類別上的方法可以指派給基本類別。

例:

class GradeCounter {
  countGrades(grades: string[], letter: string) {
    // 回傳 number
    return grades.filter(grade => grade === letter).length;
  }
}

class FailureCounter extends GradeCounter {
  // 此覆寫是ok的
  countGrades(grades: string[]) {
    return super.countGrades(grades, "F");
  }
}

class Any FailureChecker extends GradeCounter {
  // 此覆寫會失敗,因為回傳的型別不一致
  countGrades(grades: string[]) {
    // 回傳 boolean
    return super.countGrades(grades, "F") !== 0;
  }
}

const counter: GradeCounter = new AnyFailureChecker();

// 預期型別:number
// 實際型別:boolean
const count = counter.countGrades(["A", "C", "F"]);

覆寫屬性

子類別也可以用相同的名稱,明確重新宣告其基本類別的屬性,只要新型別可以指派給基本類別上的型別。

在以下範例中,基本類別 Assignment 將 grade 宣告為 number | undefined,而子類別 GradedAssignment 將其宣告為必須存在的 number:

class Assignment {
  // number | undefined
  grade?: number;
}

class GradedAssignment extends Assignment {
  // 覆寫成功,此型別為 number
  grade: number;

  constructor(grade: number) {
    super();
    this.grade = grade;
  }
}

下例是錯誤的:

class NumericGrade {
  value = 0;
}

class VagueGrade extends NumericGrade {
  // 以下覆寫失敗,因為可能會是 string 型別
  value = Math.random() > 0.5 ? 1 : "aaa";
}

const instance: NumericGrade = new VagueGrade();

// 預期型別:number
// 實際型別:number | string
instance.value;

抽象類別

在專案中,有時會建立一個本身不宣告方法的實作,期望提供一個基本類別作為子類別實作的參考。將類別標記為抽象,可將 TypeScript 的 abstract 關鍵字,加在類別名稱或任何打算抽象的方法之前。

以下範例中 School 類別及其 getStudentTypes 方法被標記為 abstract。因此,它的子類別 Preschool 和 Absence 應該實作 getStudentTypes:

abstract class School {
  readonly name: string;

  constructor(name: string) {
    this.name = name;
  }

  abstract getStudentTypes(): string[];
}

class Preschool extends School {
  getStudentTypes() {
    return ["abc"];
  }
}

// 報錯:類別 Absence 未實作從類別 School 繼承而來的 getStudentTypes
class Absence extends School {

}

抽象(abstract)類別不能直接實體化,因為沒有對其實作,僅只假設可能確實存在某些方法的定義。只有非抽象類別能實體化。例:

let school: School;
school = new Preschool("aaa"); // 正確

// 報錯:無法建立抽象類別的執行個體
school = new School("bbb");

成員的可見性

包括 JavaScript,若類別成員名稱,前面有#做開頭,則是標記為 private 類別成員。private 類別成員只能由該類別的實體做存取。如果類別之外的程式碼試圖存取的話,會報錯。

TypeScript 的成員可見性,是透過增加以下關鍵字來達成:

  • public:允許任何人、任何地方存取。
  • protected:只允許類別本身及其子類別存取。
  • private:只允許類別本身存取。

以上關鍵字只存在於型別系統中。當程式碼編譯為 JavaScript 時,都會被刪除。

例:

class Base {
  isPublicImplicit = 0; // 這會隱含為 public
  public isPublicExplicit = 1;

  protected isProtected = 2;

  private isPrivate = 3;
  #truePrivate = 4; // 前面有 # 字號,代表真正的私有屬性
}

class Subclass extends Base {
  examples() {
    this.isPublicImplicit; // 允許
    this.isPublicExplicit; // 允許
    this.isProtected; // 允許

    // 報錯:isPrivate 是私有屬性,只可從類別 Base 中存取
    this.isPrivate;

    // 報錯:因為屬性 #truePrivate 具有私人識別碼,所以無法在類別 Base 外存取這個屬性
    this.#truePrivate;
  }
}

new Subclass().isPublicImplicit; // 允許
new Subclass().isPublicExplicit; // 允許

// 報錯:isProtected 是受保護的屬性,只可從類別 Base 及其子類別中存取。
new Subclass().isProtected;

// 報錯:isPrivate 是私有屬性,只可從類別 Base 中存取。
new Subclass().isPrivate;

註:# 字號是表示私有欄位,它在執行時 JavaScript 中才是真正的私有,因為 # 字號是 JavaScript 中本來就有的語法。

可見性修飾符號可以和 readonly 一起使用。要將成員宣告為 readonly 並且為可見的,那麼可見性會是第一順位被考慮的。例:

class TwoKeywords {
  private readonly name: string;

  constructor() {
    this.name = "aaa"; // 允許
  }

  log() {
    console.log(this.name); // 允許
  }
}

const two = new TwoKeywords();

// 報錯:name 是私有屬性,只可從類別 TwoKeywords 中存取。
// 報錯:name 是唯讀,所以無法將資料指派給 name。
two.name = "ttt";

留意:TypeScript 不允許將可見性關鍵字與 JavaScript 的新 # 私有欄位混合使用。

靜態(static)欄位修飾符號

JavaScript 允許在類別本身,使用 static 關鍵字宣告成員。例:

class Question {
  protected static readonly answer: "bash";
  protected static readonly prompt = "aaa";

  guess() {
    const answer = Question.prompt; // 允許
  }
}

// 以下會報錯:answer 是 protected,只可從類別 Question 及其子類別存取
Question.answer;

留言

發佈留言