JavaScript的物件功能十分複雜,這個章節將會用TypeScript嘗試釐清JavaScript的物件建立方式,並且利用物件導向觀念來實作程式。
建立物件
JavaScript建立物件的方式有三種,第一種是用大括號{}
,第二種是用函數,第三種是用類別。在TypeScript中,任何物件的型別均屬於object
型別,而上一章介紹的陣列和函數也是屬於物件的一種。
大括號{}
物件
在JavaScript中,可以直接用一對大括號,也就是{}
來表示一個沒有任何欄位的「空物件」。
如下:
let o = {};
以上o
變數儲存的值為{}
這個物件的參考(reference)。此時的o
變數的型別也為{}
。如下:
let o: {} = {};
利用大括號產生出來的物件時,可以在大括號中直接以key: value
的格式來添加物件屬性(欄位或方法)。如果有多個屬性,就用逗號,
隔開。例如:
let o = {
name: "David",
age: 18,
toString: function () {
return `${this.age}: ${this.name}`;
},
};
console.log(o.toString());
以上程式會輸出:
以上程式的o
變數的型別為{ name: string, age: number, toString: () => string }
(用逗號,
分隔屬性),或{ name: string; age: number; toString: () => string }
(用分號;
分隔屬性)。toString
是o
物件的方法,在這方法的程式實作區塊中,使用了this
關鍵字來表示目前這個物件的參考。這邊要注意的是,如果是使用Lambda語法來定義函數,則無法使用this
關鍵字來取得物件自身的參考。如下:
let o: { name: string, age: number, toString: () => string } = {
name: "David",
age: 18,
toString: () => `${this.age}: ${this.name}`,
};
console.log(o.toString());
以上程式會編譯失敗。
不過在JavaScript中,我們可以在物件的方法內直接存取用來存放物件參考的變數或常數。因此以上程式可以修改為:
let o: { name: string, age: number, toString: () => string } = {
name: "David",
age: 18,
toString: () => `${o.age}: ${o.name}`,
};
console.log(o.toString());
雖然我們在用大括號語法來建立物件時,屬性的鍵值是直接填上一個名稱,但這個名稱實際上會被當作字串來處理。所以以上程式其實就等同於:
let o: { name: string, age: number, toString: () => string } = {
"name": "David",
"age": 18,
"toString": () => `${o.age}: ${o.name}`,
};
console.log(o.toString());
在某些情況下,我們必須明確地用字串來當作物件屬性的鍵值。例如student-number
這個名稱,因為其中帶有減號-
,所以必須要明確地用字串來表示。如下:
let o: { name: string, age: number, toString: () => string, "student-number": string } = {
name: "David",
age: 18,
toString: () => `${o.age}: ${o.name}`,
"student-number": "B12345678",
};
console.log(o.toString());
在存取物件的屬性時,也可以在物件後使用中括號[]
語法,在中括號中傳入要存取的字串鍵值。如下:
let o: { name: string, age: number, toString: () => string, "student-number": string } = {
name: "David",
age: 18,
toString: () => `${o.age}: ${o.name}`,
"student-number": "B12345678",
};
console.log(o["toString"]());
console.log(o["student-number"]);
如果想要讓物件擁有可選的屬性,可以在定義物件型別時,在屬性名稱後面加上問號?
。例如要將student-number
這個屬性變成可選的,程式如下:
let o: { name: string, age: number, toString: () => string, "student-number"?: string } = {
name: "David",
age: 18,
toString: () => `${o.age}: ${o.name}`,
};
console.log(o["student-number"]);
o["student-number"] = "B12345678";
console.log(o["student-number"]);
以上程式第7行,會輸出undefined
,因為我們在建立o
物件時,並沒有替其添加student-number
屬性,如果查詢的鍵值並不是該物件的所擁有的話,就會回傳undefined
。所以student-number
這個屬性的型別雖然被明確定義為string
,但它實際上是string | undefined
。
若想要用Lambda語法來實作可以回傳大括號物件的函數,根據前面的章節提到的在=>
後直接加上表達式的語法,我們很可能會寫出如下的程式:
let createObject = (name: string, age: number, studentNumber?: string) => {
name: name,
age: age,
toString: function () {
return `${this.age}: ${this.name}`;
},
"student-number": studentNumber,
};
let o1 = createObject("David", 18, "B12345678");
let o2 = createObject("Steven", 19);
然而以上程式有嚴重的語法錯誤,因為在=>
後接上大括號{}
的話就變成是程式敘述區塊了。為了解決這個問題,我們可以替大括號物件再圍上小括號()
。如下:
let createObject = (name: string, age: number, studentNumber?: string) => ({
name: name,
age: age,
toString: function () {
return `${this.age}: ${this.name}`;
},
"student-number": studentNumber,
});
let o1 = createObject("David", 18, "B12345678");
let o2 = createObject("Steven", 19);
注意這邊的toString
方法必須要使用this
關鍵字來存取物件本身的屬性。
物件的屬性名稱除了可以直接打上名稱或是使用字串之外,也可以用型別為number
、string
或symbol
的變數和常數來代替,只要在定義屬性名稱時替這些變數和常數加上中括號[]
包起來即可。例如:
const NAME = "name";
const AGE = "age";
const TO_STRING = "toString";
const STUDENT_NUMBER = "student-number";
let o: { [NAME]: string, [AGE]: number, [TO_STRING]: () => string, [STUDENT_NUMBER]: string } = {
[NAME]: "David",
[AGE]: 18,
[TO_STRING]: () => `${o[AGE]}: ${o[NAME]}`,
[STUDENT_NUMBER]: "B12345678",
};
console.log(o.toString());
console.log(o["student-number"]);
再例如:
const NAME = Symbol();
const AGE = Symbol();
const TO_STRING = Symbol();
const STUDENT_NUMBER = Symbol();
let o: { [NAME]: string, [AGE]: number, [TO_STRING]: () => string, [STUDENT_NUMBER]: string } = {
[NAME]: "David",
[AGE]: 18,
[TO_STRING]: () => `${o[AGE]}: ${o[NAME]}`,
[STUDENT_NUMBER]: "B12345678",
};
console.log(o[TO_STRING]());
console.log(o[STUDENT_NUMBER]);
值得注意的是,即便我們使用除了字串以外的變數作為物件的屬性名稱,number
型別實際上在執行階段仍會被自動轉成字串來使用。在TypeScript中,還是要遵照正確型別的鍵值來存取屬性才行。
const i = "5";
const ii = 5;
const o = {
[i]: "567",
[ii]: "567",
};
for (const k in o) {
console.log(k, typeof k);
}
以上程式的執行結果為:
再來看看下面這個程式:
const i = 5;
const o = { [i]: "567" };
console.log(o[5]);
console.log(o["5"]);
以上程式可以通過編譯,但是若將程式改為以下這樣:
const i = 5;
const o = { [i]: "567" };
console.log(o[6]);
console.log(o["6"]);
以上程式,在嚴格模式下,第5行和第6行都會編譯失敗。這是因為TypeScript在嚴格模式下知道o
物件此時只有一個屬性名稱為5
的屬性,所以用6
或是"6"
作為鍵值來存取屬性才不行的嗎?也不是,事實上,i
的型別是5
,而不是我們前面所說的number
,這是因為i
是一個「常數」。所以TypeScript在嚴格模式下只允許存取o
物件時須使用型別5
或是"5"
的鍵值來存取屬性。有關於將定數作為型別的用法,會在之後的章節更加詳細介紹。
若將i
改用let
或是var
關鍵字來宣告,會使得i的型別變為number
,則只有第6行會編譯失敗。但是,此時我們也不能使用"5"
作為鍵值來存取o
物件的屬性。
let i = 5;
const o = { [i]: "567" };
console.log(o[6]);
console.log(o["5"]);
以上程式,在嚴格模式下,第6行會編譯失敗。
函數物件
在JavaScript中,任意函數都可以利用new
關鍵字來建立出物件實體。例如:
function F() {
}
let a = new F();
console.log(typeof a);
console.log(a);
以上程式會輸出:
F {}
F
是一個沒有參數也沒有程式敘述的函數,new F()
可以建立出一個未知型別的物件。
在嚴格模式下,以上程式會編譯失敗,因為編譯器無法將未知型別的物件直接推論為any
。在這個情況下,我們也只能利用前面的章節介紹過的as
關鍵字,將F
函數強制轉型為any
,來避開編譯器的規則。
如下:
function F() {
}
let a = new (F as any)();
console.log(typeof a);
console.log(a);
事實上,F
函數的建構子即為自己本身,F
函數的參數就是建構子的參數,在F
函數的主體中可以直接使用this
關鍵字來存取到自身的屬性。例如:
function F(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.toString = () => `${this.name}: ${this.age}`;
this.studentNumber = studentNumber;
}
let o1 = new (F as any)("David", 18, "B12345678");
let o2 = new (F as any)("Steven", 19);
console.log(o1.toString());
console.log(o2.toString());
以上程式在嚴格模式下會編譯失敗,因為TypeScript的編譯器無法將this
的型別自動推論為any
,所以在存取this.name
、this.age
等屬性時會有問題。為了解決這個問題,我們必須要替this
指定型別。
在TypeScript中,可以將this
作為參數名稱使其成為函數的第一個參數,如此一來就可以設定this
的型別了。如下:
function F(this: any, name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.toString = () => `${this.name}: ${this.age}`;
this.studentNumber = studentNumber;
}
let o1 = new (F as any)("David", 18, "B12345678");
let o2 = new (F as any)("Steven", 19);
console.log(o1.toString());
console.log(o2.toString());
或是:
function F(this: {name: string, age: number, toString: () => string, studentNumber?: string}, name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.toString = () => `${this.name}: ${this.age}`;
this.studentNumber = studentNumber;
}
let o1 = new (F as any)("David", 18, "B12345678");
let o2 = new (F as any)("Steven", 19);
console.log(o1.toString());
console.log(o2.toString());
以上程式輸出結果為:
Steven: 19
還記得我們在上一章有提到symbol
型別的值可以拿來實作私有的屬性嗎?我們只要在物件的建構子中用Symbol
函數建立symbol
型別的值,來當物件屬性的鍵值,這個屬性就會是私有的,因為外部無法建立一個一模一樣的symbol
型別的值出來當作要查詢的鍵值。
例如要將F
函數產生出來的物件的name
和age
欄位都變成私有的話,程式改寫如下:
function F(this: any, name: string, age: number, studentNumber?: string) {
const NAME = Symbol();
const AGE = Symbol();
this[NAME] = name;
this[AGE] = age;
this.toString = () => `${this[NAME]}: ${this[AGE]}`;
this.studentNumber = studentNumber;
}
let o1 = new (F as any)("David", 18, "B12345678");
let o2 = new (F as any)("Steven", 19);
console.log(o1.toString());
console.log(o2.toString());
不過如果要使用私有屬性的話,this
的型別貌似就只能是any
了,除非我們把它做成雙層函數,如下:
function F(name: string, age: number, studentNumber?: string) {
const NAME = Symbol();
const AGE = Symbol();
function inner(this: { [NAME]: string, [AGE]: number, toString: () => string, studentNumber?: string }, name: string, age: number, studentNumber?: string) {
this[NAME] = name;
this[AGE] = age;
this.toString = () => `${this[NAME]}: ${this[AGE]}`;
this.studentNumber = studentNumber;
}
return new (inner as any)(name, age, studentNumber);
}
let o1 = new (F as any)("David", 18, "B12345678");
let o2 = new (F as any)("Steven", 19);
console.log(o1.toString());
console.log(o2.toString());
在函數物件的建構子中回傳的值,即為使用new
關鍵字時建立出來的物件。如果函數的建構子不回傳值,則會用this
作為使用new
關鍵字時建立出來的物件。
類別物件
JavaScript的函數物件非常不適合在TypeScript上使用,因為型別實在是太不好處理了!還好在ES6之後,JavaScript新增了類別物件的功能,使我們可以直接使用class
關鍵字來定義類別。
在TypeScript中,類別的語法結構如下:
class 類別名稱 { 欄位1 欄位2 ... 欄位n 建構子 方法1 方法2 ... 方法n }
欄位的撰寫方式類似使用var
、let
、const
關鍵字來宣告字串或常數那樣,但不需要寫上var
、let
、const
關鍵字,並且在欄位名稱的後面可以加上問號?
來表示該欄位為可選欄位。欄位、建構子和方法的撰寫順序可以隨意,並不會影響程式的運作。類別不一定要明確寫出建構子,如果不撰寫建構子的話,會被當作是有一個無任何參數的建構子。
建構子和方法的撰寫方式類似使用function
關鍵字來建立函數那樣,但不需要寫上function
關鍵字。
拿剛才撰寫過的函數物件來舉例:
function F(this: any, name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.toString = () => `${this.name}: ${this.age}`;
this.studentNumber = studentNumber;
}
以上程式可以用類別物件改寫為:
class F {
name: string;
age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
若要建立類別F
的物件實體,與函數物件一樣,也是要使用new
關鍵字。不過TypeScript可以正常推論類別的建構子,所以不需要再像函數物件那樣用as
關鍵字來轉型了!而且產生出來的物件型別就是類別本身。
例如:
class F {
name: string;
age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o1: F = new F("David", 18, "B12345678");
let o2: F = new F("Steven", 19);
對於私有屬性,我們也只要在屬性名稱前,加上private
修飾子即可。如下:
class F {
private name: string;
private age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
以上程式將F
類別的name
和age
物件欄位的存取權限設為私有的了。
建構子也可以使用private
來修飾,如果這樣做的話,這個類別就無法被實體化。
既然有private
修飾子,那有public
修飾子嗎?當然有!public
修飾子可以用在建構子的參數上,能使該參數直接變成一般的物件屬性,並且可以直接透過建構子參數來初始化,不必再在建構子主體中撰寫this.xxx = xxx
這樣的程式敘述。
例如:
class F {
name: string;
age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: F = new F("David", 18, "B12345678");
console.log(o.name);
console.log(o.age);
console.log(o.studentNumber);
以上程式可以修改成:
class F {
constructor(public name: string, public age: number, public studentNumber?: string) {
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: F = new F("David", 18, "B12345678");
console.log(o.name);
console.log(o.age);
console.log(o.studentNumber);
物件的原型(Prototype)與繼承(Inheritance)
事實上,我們剛才寫的類別物件:
class F {
name: string;
age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
它其實更接近於以下這個函數物件:
function F(this: any, name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
F.prototype.toString = function() {
return `${this.name}: ${this.age}`;
};
直接在類別物件中定義的方法,都會變成類別本身的prototype
屬性下的方法。
當我們要存取一個物件的屬性時,例如要存取toString
這個屬性好了,JavaScript會先去尋找物件本身有沒有toString
屬性,如果沒有的話,就會去尋找該物件的上級物件的prototype
屬性下有沒有toString
屬性。如果還是沒有,就會去尋找這個上級物件的prototype
屬性下的__proto__
屬性有沒有toString
屬性。如果還是沒有,就會去尋找這個上級物件的prototype
屬性下的__proto__
屬性下的__proto__
屬性下有沒有toString
屬性。依此類推,直到找不到可用的__proto__
屬性為止。此時若物件是陣列,就會去找Array
這個JavaScript內建物件的prototype
屬性下的toString
屬性,如果還是沒有,就會去找Object
這個JavaScript內建物件的prototype
屬性下的toString
屬性;若此時物件不是陣列,就會直接去找Object
的prototype
屬性下的toString
屬性。
順帶一提,number
型別的值在尋找屬性的時候,會先去找Number
這個JavaScript內建物件的prototype
屬性,再去找Object
的prototype
屬性;string
型別的值在尋找屬性的時候,會先去找String
這個JavaScript內建物件的prototype
屬性,再去找Object
的prototype
屬性。依此類推。
使用new
關鍵字產生出來的函數物件實體,其上級物件即為該函數本身。如果我們要讓函數物件去繼承其它的函數物件或是類別的話,可以直接去設定函數的prototype
屬性的__proto__
屬性為其它函數物件或是類別的prototype
屬性。
舉例來說:
function User(this: { name: string, type: string }, name: string) {
this.name = name;
this.type = "user";
}
User.prototype.whoAmI = function () {
return `My name is ${this.name}. I am a ${this.type}.`;
};
function Human(this: { name: string, type: string }, name: string) {
User.apply(this, [name]);
this.type = "human";
}
Human.prototype.__proto__ = User.prototype;
function Robot(this: { name: string, type: string }, name: string) {
User.apply(this, [name]);
this.type = "robot";
}
Robot.prototype.__proto__ = User.prototype;
function Student(this: { name: string, type: string }, name: string) {
Human.apply(this, [name]);
this.type = "student";
}
Student.prototype.__proto__ = Human.prototype;
let o1 = new (User as any)("Unknown");
let o2 = new (Human as any)("David");
let o3 = new (Robot as any)("WALL-E");
let o4 = new (Student as any)("Steven");
console.log(o1.whoAmI());
console.log(o2.whoAmI());
console.log(o3.whoAmI());
console.log(o4.whoAmI());
以上程式,Student
繼承Human
,Human
繼承User
,Robot
繼承User
。利用了函數提供的apply
方法來指定要使用相同的this
所儲存的物件參考來呼叫函數。
執行結果如下:
My name is David. I am a human.
My name is WALL-E. I am a robot.
My name is Steven. I am a student.
當然,以上程式如果使用類別物件來改寫的話,會變得易讀許多。改寫結果如下:
class User {
name: string;
type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
class Robot extends User {
constructor(name: string) {
super(name);
this.type = "robot";
}
}
class Student extends Human {
constructor(name: string) {
super(name);
this.type = "student";
}
}
let o1 = new User("Unknown");
let o2 = new Human("David");
let o3 = new Robot("WALL-E");
let o4 = new Student("Steven");
console.log(o1.whoAmI());
console.log(o2.whoAmI());
console.log(o3.whoAmI());
console.log(o4.whoAmI());
在類別名稱後面加上extends
關鍵字,可以指定該類別要繼承的類別。在子類別的建構子中,必須要在第一個程式敘述使用super
關鍵字來呼叫父類別的建構子。
如果我們把User
類別的type
物件欄位改成私有的,確保其不能被外部程式更改的話,會發現其子類別均無法成功存取type
物件欄位而造成TypeScript編譯錯誤。像這種需要讓屬性能夠只公開到子類別的情況,我們要將屬性的修飾子改為protected
,而不是private
。如下:
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
class Robot extends User {
constructor(name: string) {
super(name);
this.type = "robot";
}
}
class Student extends Human {
constructor(name: string) {
super(name);
this.type = "student";
}
}
let o1 = new User("Unknown");
let o2 = new Human("David");
let o3 = new Robot("WALL-E");
let o4 = new Student("Steven");
console.log(o1.whoAmI());
console.log(o2.whoAmI());
console.log(o3.whoAmI());
console.log(o4.whoAmI());
替物件的屬性添加適合的存取修飾子,正是物件導向程式設計的封裝(Encapsulation)觀念。
判斷物件是否屬於指定的類別
既然物件有了繼承關係,我們就很可能會需要在程式執行階段去判斷物件所屬的類別。在JavaScript中,提供了instanceof
關鍵字,可以用來判斷一個物件是否屬於指定的類別。
例如:
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
class Robot extends User {
constructor(name: string) {
super(name);
this.type = "robot";
}
}
class Student extends Human {
constructor(name: string) {
super(name);
this.type = "student";
}
}
const o1: User = new User("Unknown");
const o2: User = new Human("David");
const o3: User = new Robot("WALL-E");
const o4: User = new Student("Steven");
console.log(o1 instanceof User);
console.log(o2 instanceof User);
console.log(o3 instanceof User);
console.log(o4 instanceof User);
console.log(o4 instanceof Human);
console.log(o4 instanceof Robot);
console.log(o4 instanceof Student);
以上程式的執行結果如下:
true
true
true
true
false
true
以上程式的36行到39行,由於Human
、Robot
、Student
類別都繼承User
類別,因此它們的實體的型別也可以是User
。這就是物件導向的多型(Polymorphism)觀念。
類別(靜態)屬性
除了將類別實體化出來後的物件能夠擁有屬性之外,我們也可以讓類別本身也擁有屬性。只要在撰寫類別程式的時候,在屬性名稱前加上static
關鍵字,即可使其成為類別屬性。
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
static printInformation() {
console.log("User");
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
static printInformation() {
console.log("Human");
}
}
class Robot extends User {
constructor(name: string) {
super(name);
this.type = "robot";
}
static printInformation() {
console.log("Robot");
}
}
class Student extends Human {
constructor(name: string) {
super(name);
this.type = "student";
}
}
User.printInformation();
Human.printInformation();
Robot.printInformation();
Student.printInformation();
以上程式的輸出結果如下:
Human
Robot
Human
類別屬性是可以被繼承的,但是我們無法透過類別實體化出來的物件來存取類別屬性。如果要替類別屬性加上存取修飾子,要添加在static
關鍵字之前。
唯讀欄位
在撰寫類別程式的時候,在欄位名稱前加上readonly
關鍵字,即可使其成為唯讀欄位。
class User {
readonly name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
static printInformation() {
console.log("User");
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
static printInformation() {
console.log("Human");
}
}
class Robot extends User {
constructor(name: string) {
super(name);
this.type = "robot";
}
static printInformation() {
console.log("Robot");
}
}
class Student extends Human {
constructor(name: string) {
super(name);
this.type = "student";
}
}
User.printInformation();
Human.printInformation();
Robot.printInformation();
Student.printInformation();
若我們想嘗試修改User
或是其子類別的物件的name
欄位,就會編譯錯誤。只有在類別自己的建構子中,才可以修改唯讀欄位。
如果有存取修飾子的存在,readonly
關鍵字要加在存取修飾子之後。如果有static
關鍵字的存在,readonly
關鍵字要加在static
關鍵字之後。
大括號物件也可以有唯讀欄位,只要在型別中的屬性名稱前加上readonly
關鍵字就好。例如:
let o: { readonly name: string, readonly age: number, toString: () => string } = {
name: "David",
age: 18,
toString: () => `${o.age}: ${o.name}`,
};
函數(方法)多載
類別中的函數,或者說物件的方法,當然也可以多載。例如:
class F {
name: string;
age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString(): string;
toString(includeStudentNumber: boolean): string;
toString(includeStudentNumber?: boolean) {
if (includeStudentNumber) {
if (typeof this.studentNumber === "string") {
return `${this.name}: ${this.age}, ${this.studentNumber}`;
}
}
return `${this.name}: ${this.age}`;
}
}
呼叫父類別的方法
super
關鍵字也可以用來呼叫父類別(被覆寫前的)的方法。例如:
class S {
a() {
console.log("a");
}
b() {
console.log("b");
}
}
class C extends S {
a() {
console.log("A");
}
b() {
console.log("B");
}
ab() {
this.a();
super.b();
}
}
new C().ab();
以上程式會輸出:
b
getter 和 setter
對於私有欄位,我們可以透過物件方法來存取它。例如:
class User {
readonly name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
getType(): string {
return this.type;
}
setType(t: string) {
if (t.length > 0) {
this.type = t;
} else {
this.type = "user";
}
}
}
let user = new User("David");
console.log(user.getType());
user.setType("human");
console.log(user.getType());
以上User
物件的getType
方法即為一個「getter」,setType
方法即為一個「setter」。
事實上,若TypeScript的編譯目標為ES5
或是以上的版本的話,我們可以在類別的方法前加上set
或是get
關鍵字,來讓該方法變成一個「getter」或一個「setter」,使該方法的屬性名稱變成「可以被安全存取的欄位」。例如:
class User {
readonly name: string;
protected _type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
get type(): string {
return this._type;
}
set type(t: string) {
if (t.length > 0) {
this._type = t;
} else {
this._type = "user";
}
}
}
let user = new User("David");
console.log(user.type);
user.type = "human";
console.log(user.type);
被加上set
或是get
關鍵字的方法不可以設定存取修飾子,但是可以在其前面加上static
關鍵字使其變成靜態屬性。
any
、object
與物件型別的轉換
前面有提到過,在TypeScript中,任何物件的型別均屬於object
型別。object
型別即{}
型別,它無法存取任何的屬性。
我們在先前的章節也有提到過,any
可以用來表示任意型別,所以我們可以將任意物件轉成any
型別。
例如:
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: any = new F("David", 18);
o.name = 18;
o.age = "David";
o.other = true;
以上程式,雖然o
變數是F
類別的物件,但它型別被明確定義為any
,因此可以無限制地操作F
物件的屬性。
事實上,以上這個F類別的物件型別,也可以用{readonly name: string, readonly age: number, readonly studentNumber?: string, toString: () => string }
來表示。例如:
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: {readonly name: string, readonly age: number, readonly studentNumber?: string, toString: () => string} = new F("David", 18);
我們可以透過型別來「隱藏」物件的某些屬性。例如要隱藏studentNumber
屬性:
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: {readonly name: string, readonly age: number, toString: () => string} = new F("David", 18);
我們甚至可以把型別中的readonly
關鍵字直接拿掉,讓它的唯讀欄位變成可讀寫欄位。
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: {name: string, age: number, toString: () => string} = new F("David", 18);
o.name = "Steven";
o.age = 19;
令人震驚的是,我們其實可以把任意值的型別改為其它完全不相關的型別,只要利用any
來做中介轉型即可。
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: { firstName: string, lastName: string } = new F("David", 18) as any;
o.firstName = "Steven";
o.lastName = "Nigel";
以上程式是可以通過編譯並且成功執行的。
由於TypeScript的型別並不是這麼的安全,因此在使用冒號:
接上型別來明確定義變數或是參數的型別時,或是使用as
關鍵字接上型別來進行轉型時,要特別注意原本值的型別。
能夠任意添加屬性的物件型別
any
型別除了能夠讓我們自由存取物件的屬性之外,TypeScript還提供了另一種比較安全的方式。先看以下程式:
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: {name: string, age: number, toString: () => string} = new F("David", 18);
如果我們想要讓o
物件能夠在保有類別F
的屬性限制下,新增額外的任意屬性,可以在其型別上加上這樣的屬性[_: string]: any
。如下:
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: { name: string, age: number, toString: () => void, [_: string]: any } = new F("David", 18);
o.a = 1;
o.b = "2";
o.c = false;
[_: string]: any
語法看起來很怪異,它的意思是:這個型別的所有物件屬性的鍵值都必須是string
,且值的型別可為任意型別。屬性的鍵值可以使用數值或是字串,大部份的情況下會用字串,就算是用數值也是可以用字串來存取屬性。在型別的屬性中有了[_: string]: any
後,我們就可以替這個型別的物件添加任意屬性,存放任意型別的值了!
再舉一個例子,如果要設計一個屬性值只能是數值的物件,程式如下:
let o: { [_: string]: number } = {};
o.a = 1;
o.b = 2;
利用is
關鍵字來縮減型別範圍
還記得上一章中,我們寫的以下這段程式嗎?
function save(path: string, data_force?: string | boolean, force?: boolean): boolean {
let data;
const t_data_force = typeof data_force;
if (t_data_force === "undefined") {
data = "Hello, world!";
force = false;
} else if (t_data_force === "boolean") {
data = "Hello, world!";
force = data_force as boolean;
} else { // string
data = data_force;
}
const fs = require("node:fs");
if (fs.existsSync(path)) {
if (force) {
fs.unlinkSync(path);
} else {
return false;
}
}
fs.writeFileSync(path, data);
return true;
}
為了讓我們的程式更安全,我應該要儘量避免轉型的發生。TypeScript提供了is
關鍵字,可以用在函數的回傳值上,來幫助TypeScript的編譯器限縮值的型別範圍。
以上程式,由於data_force
參數的型別為string | boolean | undefined
,所以我們其實可以加入以下利用了is
關鍵字實作的兩個函數,來讓編譯器能夠正確判斷data_force
的型別。
function save(path: string, data_force?: string | boolean, force?: boolean): boolean {
let data;
const t_data_force = typeof data_force;
function isUndefined(data_force?: string | boolean): data_force is undefined {
return t_data_force === "undefined";
}
function isBoolean(data_force?: string | boolean): data_force is boolean {
return t_data_force === "boolean";
}
if (isUndefined(data_force)) {
data = "Hello, world!";
force = false;
} else if (isBoolean(data_force)) {
data = "Hello, world!";
force = data_force;
} else { // string
data = data_force;
}
const fs = require("node:fs");
if (fs.existsSync(path)) {
if (force) {
fs.unlinkSync(path);
} else {
return false;
}
}
fs.writeFileSync(path, data);
return true;
}
題外話,如果值的型別全都被編譯器限縮到沒有的話,會發生什麼事呢?
例如:
function foo(x: string | number): boolean | void {
const t_x = typeof x;
function isString(data_force: string | number): data_force is string {
return t_x === "string";
}
function isNumber(data_force: string | number): data_force is number {
return t_x === "number";
}
if (isString(x)) {
return true;
} else if (isNumber(x)) {
return false;
}
}
當程式執行完第16行之後,x
變數的型別是什麼?它的型別其實是never
!
function foo(x: string | number): boolean | void {
const t_x = typeof x;
function isString(data_force: string | number): data_force is string {
return t_x === "string";
}
function isNumber(data_force: string | number): data_force is number {
return t_x === "number";
}
if (isString(x)) {
return true;
} else if (isNumber(x)) {
return false;
}
let xx: never = x;
}
在TypeScript中,用never
來表示「沒有型別」。
判斷物件是否有指定的屬性
in
關鍵字,除了可以用來製作for-in迴圈之外,還可以判斷一個物件是否有指定的屬性。例如:
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
const o1 = new User("Unknown");
const o2 = new Human("David");
console.log("name" in o1);
console.log("whoAmI" in o1);
console.log("name" in o2);
console.log("whoAmI" in o2);
console.log(o1.hasOwnProperty("name"));
console.log(o1.hasOwnProperty("whoAmI"));
console.log(o2.hasOwnProperty("name"));
console.log(o2.hasOwnProperty("namwhoAmIe"));
以上程式會輸出:
true
true
true
true
false
true
false
物件的hasOwnProperty
方法可以判斷物件是否自己擁有(而非透過prototype
)指定的屬性。其實在使用for-in迴圈來走訪物件的屬性時,通常還需要使用hasOwnProperty
方法來過濾掉非自身的屬性。
例如:
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
const o = new Human("David");
for (const key in o) {
console.log(key);
}
以上程式會輸出:
name
constructor
whoAmI
而我們通常在使用for-in迴圈時,只是要得到物件本身擁有的屬性,所以要把程式寫成:
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
const o = new Human("David");
for (const key in o) {
if (o.hasOwnProperty(key)) {
console.log(key);
}
}
以上程式會輸出:
name
您可能會覺得很奇怪,既然物件有hasOwnProperty
方法,為什麼hasOwnProperty
在使用for-in迴圈的時候不會被走訪呢?這是因為JavaScript的屬性其實是有一些「選項」可以設定的,能夠被for-in迴圈走訪到的屬性,它的enumerable
選項必須要被設為true
(預設值正是true
)。然而hasOwnProperty
屬性的enumerable
選項是設為false
,所以它不會被for-in迴圈走訪。
JavaScript內建的Object
物件,提供了defineProperty
和defineProperties
方法,可以用比較低階的方式來設定任意型別的值的屬性。由於這部份並不是本系列文章的範圍,在此就點到為止。
總結
在這個章節中,我們學到了大括號物件、函數物件和類別物件,這三種JavaScript物件的建立方式,稍微掌握了物件導向程式設計的方式,也了解了TypeScript可怕的轉型機制,然後還學會了instanceof
、is
和in
關鍵字的用法。在下一個章節中,要來學習TypeScript提供的介面用法。