Skip to main content

Ch6: Defend the data

6.1 Encapsulating without getters

6.1.1 Rule: DO NOT USE GETTERS OR SETTERS

tip

Do not use setters or getters for non-Boolean fields.

原因是:

  1. setter和getter會使類的不變量變得全局,破壞了封裝性。 任何獲得該對象的人都可以呼叫getter和setter,構成未知的修改。
  2. 使用getter和setter會導致pull-based的架構; 這通常會產生不必要的數據類和肥大的管理類,且數據類和管理類會緊密耦合。

Pull-based vs Pushed-based

Pull-based architecture
class Website {
constructor(private url: string) { }
getUrl() {
return this.url;
}
}
class User {
constructor(private username: string) { }
getUsername() { return this.username; }
}




class BlogPost {
constructor(private author: User,
private id: string) { }
getId() { return this.id; }
getAuthor() { return this.author; }
}



function generatePostLink(website: Website,
post: BlogPost) {
let url = website.getUrl();
let user = post.getAuthor();
let name = user.getUsername();
let postId = post.getId();
return url + name + postId;
}
Push-based architecture
class Website {
constructor(private url: string) { }
generateLink(name: string, id: string) {
return this.url + name + id;
}
}
class User {
constructor(private username: string) { }
generateLink(website: Website, id: string) {
return website.generateLink(
this.username,
id);
}
}
class BlogPost {
constructor(private author: User,
private id: string) { }
generateLink(website: Website) {
return this.author.generateLink(
website,
this.id);
}
}
function generatePostLink(website: Website,
post: BlogPost) {
return post.generateLink(website);
}
note

Law of Demeter.

It means "Don't talk to strangers." A stranger in this context is an object that we do not have direct access to but can obtain a reference to.

6.1.3 Refactoring pattern: ELIMINATE GETTER OR SETTER

tip
  1. Make the getter or setter private to get errors everywhere it is used.
  2. Fix the errors with PUSH CODE INTO CLASSES.
  3. The getter or setter is inlined as part of PUSH CODE INTO CLASSES. It is therefore unused, so delete it to avoid other people trying to use it.
class Website {
constructor(private url: string) { }
getUrl() { return this.url; }
}
class User {
constructor(private username: string) { }
getUsername() { return this.username; }
}
class BlogPost {
constructor(private author: User, private id: string) { }
getId() { return this.id; }
getAuthor() { return this.author; }// from User, should make it private
}
function generatePostLink(website: Website, post: BlogPost) {
let url = website.getUrl();
let user = post.getAuthor();
let name = user.getUsername();
let postId = post.getId();
return url + name + postId;
}

6.1.4 Eliminating the final getter

Remove the final getter FallStrategy.getFalling.

class FallStrategy {
// ...
getFalling() {
return this.falling;
}
}

6.2 Encapsulating simple data

tip

Our code should not have methods or variables with common prefixes or suffixes.

當多個元素具有相同的前後綴時,它表示元素之間的一致性,所以應該把它們封裝成一個類,好處:

  1. 完全控制外部接口
  2. 隱藏不變量,確保只有在類中會修改這些不變量

符合單一職責原則。

note

single responsibility principle

Methods/Classes should do one thing.

Bad
function accountDeposit(
to: string, amount: number) {
let accountId = database.find(to);
database.updateOne(
accountId,
{ $inc: { balance: amount } });
}
function accountTransfer(amount: number,
from: string, to: string) {
accountDeposit(from, -amount);
accountDeposit(to, amount);
}
Good
class Account {
private deposit(
to: string, amount: number) {
let accountId = database.find(to);
database.updateOne(
accountId,
{ $inc: { balance: amount } });
}
transfer(amount: number,
from: string, to: string) {
this.deposit(from, -amount);
this.deposit(to, amount);
}
}

6.2.2 應用NEVER HAVE COMMON AFFIXES

例如,我們的程式碼中有以下方法和變量:

  • playerx
  • playery
  • drawPlayer

應該要把上述内容封裝起來。

interface Tile {
// ...
moveHorizontal(
dx: number): void;//會用到playerx的地方
moveVertical(
dy: number): void;//會用到playery的地方
}
/// ...
function moveToTile(
newx: number, newy: number) {
map[playery][playerx] =
new Air();
map[newy][newx] = new PlayerTile();
playerx = newx;//會用到playerx的地方
playery = newy;//會用到playery的地方
}
/// ...
let playerx = 1;//全局變數
let playery = 1;

接下來把getter/setter拿掉。

class Player {
// ...
getX() { return this.x; }// make private
getY() { return this.y; }
setX(x: number) { this.x = x; }
setY(y: number) { this.y = y; }
}

6.2.3 Refactoring pattern: ENCAPSULATE DATA

tip
  1. Create a class.
  2. Move the variables into the new class, replacing let with private. Simplify the variables’ names; also make getters and setters for the variables.
  3. Because the variables are no longer in the global scope, the compiler helps us find all the references by giving errors. Fix these errors in the following five steps:
  1. Pick a good variable name for an instance of the new class.
  2. Replace access with getters or setters on the pretend variable.
  3. If we have errors in two or more different methods, add a parameter with the variable name from earlier as the first parameter, and put the same variable as the first argument at call sites.
  4. Repeat until only one method errors.
  5. If we encapsulated variables, instantiate the new class at the point where the variables were declared. Otherwise, put the instantiation in the method that errors.
let counter = 0;
function incrementCounter() {
counter++;
}
function main() {
for (let i = 0; i < 20; i++) {
incrementCounter();
console.log(counter);
}
}

6.3 封裝複雜的資料 Encapsulate Complex Data

使用 Encapsulate Data 方法 將對 Map 的操作透過一個 Map class 完成


interface Tile{
// ...
moveHorizontal(player: Player, dx: number):void;
moveVertical(player: Player, dy: number):void;
update(x: number, y:number): void;
}

// ...
function remove(shouldRemove: RemoveStrategy){
for(let y = 0; y < map.length; y++){
for(let x = 0; x < map[y]; x++){
if(shouldRemove.check(map[y][x])){
map[y][x] = new Air();
}
}
}
}

// ...
let map: Tile[][];

接著,使用 Push code into classes 與 Inline method 簡化了 function 名稱


function transformMap(map: Map) {
map.setMap(new Array(rawMap.length));
for (let y = 0; y < rawMap.length; y++){
map.getMap()[y] = new Array(rawMap[y].length);
for (let x = 0; x < rawMap[y].length; x++){
map.getMap()[y][x] = transformTile(rawMap[y][x])
}
}
}

function updateMap(map: Map){
for (let y = map.getMap().length - 1; y >= 0; y--){
for (let x = 0; x < map.getMap()[y].length; x++) {
map.getMap()[y][x].update(map, x, y);
}
}
}

function drawMap(map: Map, g: CanvasRenderingContext2D){
for (let y = 0; y < map.getMap().length; y++){
for (let x = 0; x < map.getMap()[y].length; x++) {
map.getMap()[y][x].draw(g, x, y);
}
}
}

如同前面對於 Player 的做法,移除 getter 與 setter(Eliminate getter or setter)

class Falling {
// ...
drop(map: Map, tile: Tile, x: number, y: number){
map.getMap()[y + 1][x] = tile;
map.getMap()[y][x] = new Air();
}
}

class FallStrategy {
// ...
update(map: Map, tile: Tile, x: number, y: number){
this.falling = map.getMap()[y+1][x].isAir() ? new Falling() : new Resting();
this.falling.drop(map, tile, x, y);
}
}

class Player {
// ...
moveHorizontal(map: Map, dx: number) {
map.getMap()[this.y][this.x + dx].moveHorizontal(map, this, dx);
}
moveVertical(map: Map, dy: number) {
map.getMap()[this.y + dy][this.x].moveVertical(map, this, dy);
}
pushHorizontal(map: Map, tile: Tile, dx: number) {
if(map.getMap()[this.y][this.x + dx + dx].isAir()
&& !map.getMap()[this.y + 1][this.x + dx].isAir()){
map.getMap()[this.y][this.x + dx + dx] = tile;
this.moveToTile(map, this.x + dx, this.y);
}
}
private moveToTile(map: Map, newx: number, newy: number){
map.getMap()[this.y][this.x] = new Air();
map.getMap()[newy][newx] = new PlayerTile();
this.x = newx;
this.y = newy;
}
}

class Map {
// ...
}

Remove 這個方法也直接移進 Map (Inline Method)


function remove(map: Map, shouldRemove: RemoveStrategy){
for(let y = 0; y < map.getMap().length; y++){
for(let x = 0; x < map.getMap()[y].length; x++){
if(shouldRemove.check(map.getMap()[y][x])){
map.getMap()[y][x] = new Air();
}
}
}
}


class Map {
// ...
getMap() {
return this.map;
}
}

一般而言不會在 public 介面,像是 Player 中使用像是 setTile 這樣的存在 因此我們將 PushHorizontal 也放進 Map 裡,由 map 去呼叫 player

class Player {
// ...
pushHorizontal(map: Map, tile: Tile, dx: number) {
if(map.isAir(this.x + dx + dx, this.y)
&& !map.isAir(this.x + dx, this.y + 1)){
map.setTile(this.x + dx + dx, this.y, tile);
this.moveToTile(map, this.x + dx, this.y);
}
}
private moveToTile(map: Map, newx: number, newy: number){
map.movePlayer(this.x, this.y, newx, newy);
this.x = newx;
this.y = newy;
}
}

上述的改動讓 setTile 變成只有在 Map 中使用,可以考慮直接拿掉或是改成 private function

6.4 移除循序不變的條件

tip

循序不變性(sequence invariant):當某些程式需要在其他程式前被呼叫

在前一章節中所建立的 Map 是透過 transform 來初始化就符合這個情境,透過 constructor 強制這個 class 在被實例化的同時就需要先初始化 強制其照特定的順序執行

class Map {
// ...
transform() {
// ...
}
}
// ...
window.onload = () =>{
map.transform();
gameLoop(map);
}

6.4.1 重構模式:強制循序(Enforce Sequence)

核心:『教』編譯器如何執行我們的程式


function print(str: string){
// string should be capitalized
console.log(str)
}

Enforce Sequence 有兩種變體

  1. 內部:將目標函式移動到新的 class 內,如同前面的 CapitalizedString
  2. 外部:只將目標函式的參數改傳入一個特定型別

內部 & 外部

class CapitalizedString {
private value: string;
constructor(str: string) {
this.value = capitalize(str);
}
print() {
console.log(value);
}
}
class CapitalizedString {
private readonly value: string;
constructor(str: string) {
this.value = capitalize(str);
}
}

function print(str: CapitalizedString){
console.log(str.value);
}

Example

銀行出入帳 我們想要確保在把錢轉給對方前,錢會先從轉出方扣除

步驟如下:

  1. 使用 Encapsulate Data 重構最後要執行的方法
  2. 讓 constructor 呼叫第一個方法
  3. 如果這兩個方法的引數有關聯,把這些引數變成欄位,並從方法中移除

function deposit(to: string, amount: number){
let accountId = database.find(to);
database.updateOne(accountId, { $inc: { balance: amount } })
}

此為負向轉移的寫法.確保 amount 是在 Transfer 建立時就定義好的,但同時也會需要確保 deposit 被執行,否則錢就消失了

6.5 另一種消除 enum 的方法

enum 不支援方法

6.5.1 利用 private constructor

每個物件需要透過 constructor 建立,如果把 constructor 設為 private ,物件就只能在 class 內部被建立 可以更有效控制實例的數量 如果把這些實例放在 public const 中,就視同 enum 的用法

enum TShirtSize {
SMALL,
MEDIUM,
LARGE
}

function sizeToString(s: TShirtSize) {
if (s === TShirtSize.SMALL) {
return "S"
}else if (s === TShirtSize.MEDIUM) {
return "M"
}else if (s === TShirtSize.LARGE) {
return "L"
}
}

用 Replace Type Code With Classes 雖然可以解決 sizeToString 中 大量的 if else 但作者大多數的情況下不會選擇這樣的做法,因為在有些程式語言並不適用,例如:Java 如果要消除 enum ,選用先前的方法優先


回到遊戲範例,建立一個新類別用來消除 enum RawTile

interface RawTileValue { }
class AirValue implements RawTileValue { }
// ... implement 多個 RawTileValue
class RawTile2 {
static readonly AIR = new RawTile2(new AirValue());
// ... 放入多個 static readonly class
}

6.5.2 重新對映數值到類別

將數字轉換為新的 RawTile2,建立一個與 enum 相同的順序的陣列

enum RawTile {
AIR,
FLUX,
UNBREAKABLE,
PLAYER,
STONE,
FALLING_STONE,
BOX,
FALLING_BOX,
KEY1, LOCK1,
KEY2, LOCK2
}

有了這個陣列就可以將原本的 enum 的數字對映到相應的實例

let rawMap: RawTile[][] = [
//...
];

class Map {
private map: Tile[][];
constructor() {
this.map = new Array(rawMap.length);
for(let y = 0; y < rawMap.length; y++){
this.map[y] = new Array(rawMap[y].length);
for(let x = 0; x < rawMap[y].length; x++ ){
this.map[y][x] = transformTile(rawMap[y][x]);
}
}
}
// ...
}

function transformTile(tile: RawTile){
// ...
}

在先前重構的過程中,目前的 transformTile 會出錯,主要是因為原本是用 switch case 為消除這些 switch 以及 enum ,對 RawTile2 進行一次 Push Code Into Classes

interface RawTileValue { }
class AirValue implements RawTileValue { }
class StoneValue implements RawTileValue { }
class Key1Value implements RawTileValue { }

class RawTile2 {
// ...
}

function assertExhausted(x: never) : never {
throw new Error("Unexpected object: " + x);
}

function transformTile(tile: RawTile2){
switch(tile){
case RawTile.AIR:
return new Air();
case RawTile.STONE:
return new Stone(new Resting());
// ...
default:
assertExhausted(tile);
}
}

如此一來,switch case 與 enum 就可以被消除了,我們將 RawTile2 改為 RawTile 作為正式的類別

總結

  • DO NOT USE GETTERS OR SETTERS:以免暴露 private value ;

=> 可以透過 ELIMINATE GETTER OR SETTER 這個方法來消除

  • NEVER HAVE COMMON AFFIXES:有共同前後綴的方法或變數應該放在同一個類別做管理;

=> 可以透過 ENCAPSULATE DATA 來解決

  • 可以透過 ENFORCE SEQUENCE 強制讓 function 間的執行間有一定的順序
  • 處理 enum 的另一個方法是使用帶有 private constructor 的 class ,同時也可以消除 switch case