iT邦幫忙

2022 iThome 鐵人賽

DAY 23
0
Modern Web

這些那些你可能不知道我不知道的Web技術細節系列 第 23

你可能不知道的JS自動型別轉換

  • 分享至 

  • xImage
  •  

前言

如果要針對一個 物件陣列 取最大值該怎麼做?

一天,一位同事這麼問到。

首先需要先知道的是一般在JavaScript物件是無法比較大小的,所以這句話的意思是將物件特定屬性作為比較參考值,或是將物件數個屬性計算成一個可以比較的值後作為參考值,再進行比較尋找最大值。

起初,針對這個問題我第一想到的就是三種方式:

  1. 使用Array.prototype.reduce()
  2. 使用Array.prototype.sort()
  3. 使用Math.max()

最後一個你可能會很好奇:物件無法比較,並且Math.max()又不像Python的max()可以輸入key函式,這樣可以比較嗎?

沒錯,這個方式是存在問題的,但原因並不是因為「物件無法比較」,而是結果型別的問題,這個問題之後會提到。並且這也引發了今天的議題--「JS自動型別轉換」。

物件陣列怎麼取最大值?

這並不是今天主題的重點,也就不賣關子了,直接給出做法。

同樣拿Person類別為例子:

class Person {
    name = "";
    #birthday = new Date(); // 私有屬性
    get birthday() { return this.#birthday }; // 調整處
    addr = ""
    
    get age() {
        return new Date().getYear() - this.birthday.getYear();
    }
    
    constructor(name, birthday, addr = "") {
        this.name = name;
        this.#birthday = birthday; // 調整處
        this.addr = addr;
    }
    
    
    hello() {
        console.log(`Hello, ${this.name}.`);
    }
}

現在有10個年幼不依的人:

var people = [
    "Alice",
    "Bob",
    "Candy",
    "Danel",
    "Frank",
    "Grant",
    "Harry",
    "Iris",
    "Joe",
    "Kevin"];

people = people.map(name => new Person(name, new Date(Math.floor(Math.random() * 100) + 1990, 1), ""))

現在如果希望找到年紀最大的,這裡提供幾種方式:

使用Array.prototype.reduce()

不囉唆,直接給例子:

var MaxAgePerson = people.reduce((pre, curr) => (curr.age > pre.age) ? curr : pre);
console.log(MaxAgePerson);

使用Array.prototype.sort()

不囉唆,直接給例子:

var MaxAgePerson = people.sort((a, b) => b.age - a.age)[0];
console.log(MaxAgePerson);

注意: Array.prototype.sort()in-place的。這表示原本的物件陣列的順序也會被改變。如果不希望原物件被變更到,需要先淺複製再排序:

var MaxAgePerson = [...people].sort((a, b) => b.age - a.age)[0];
console.log(MaxAgePerson);

或是透過Array.prototype.filter()建立副本:

var MaxAgePerson = people.filter(_ => true).sort((a, b) => b.age - a.age)[0];
console.log(MaxAgePerson);

使用for迴圈

不囉唆,直接給例子:

var MaxAgePerson = people[0];

for(let person of people) {
    if(person.age > MaxAgePerson.age)
        MaxAgePerson = person;
}

console.log(MaxAgePerson);

這大概是最簡單基礎的做法了,很大機會上你也可以使用.forEach()處理。

使用.reduce().sort()可能同樣有與.forEach()相似的問題。雖然可能寫起來不好看,但有時候有所幫助。

陣列排序

使用上述方式,除了.sort()外,都只能夠取得最大的一個。如果要最大的數個呢?這就無法處理。從概念上來說,最好自己實現一個Heap結構,不過在簡單情況,可以透過先排序後,在取前幾個或後幾個得到。就像是上面示例的一樣。

不過...就必須傳入一個compareFn。能不能像字串陣列排序一樣,不帶任何參數呼叫呢?

var arr = "porttitor lacus luctus accumsan tortor posuere ac ut consequat semper"
arr = arr.split(" ")
console.log(arr.sort());

可以的,不過在說明方式之前,必須在聊聊另外一個話題--自動型別轉換。

自動型別轉換

作為一個弱型別語言,JavaScript在特定時候會進行自動型別轉換,而且這個引發的bug複雜程度讓不少人頭痛。

 強型別與弱型別的界線很糢糊。 而我個人更喜歡以資料有沒有型別來看,且從這個角度來看JavaScript也是強型別。
 另外一個也常被提到的是靜態型別和動態型別,在我看來就是變數有沒有型別。毫無疑問的JavaScript是動態型別。

比如null0的比較:

console.log(null == 0); // false, 兩者不相等
console.log(null >= 0); // true, 但是 null 大於等於 0
console.log(null <= 0); // true, 而且 null 小於等於 0 !?
console.log(null > 0); // false, 不過 null 既不大於 0
console.log(null < 0); // false, 而且 null 也不小於 0 ???

關於這個的說明,我放到下面講解。大家可以先自己想想看。

所以通常我會建議新手不要太依賴自動型別轉換。不過如果知道一些轉換規則,使用起來其實還蠻方便的。一元運算子+-將轉換成數字:

var a = 1;
var b = "2";
console.log(a + b); // "12"
console.log(a + +b); // 3

注意!
對於第二個你不能寫成:

console.log(a ++ b); // Error

但是下面這一下寫法也是可以的:

var a = 1;
var b = "2";
console.log(a++ + +b); // 3

var a = 1;
var b = "2";
console.log(a +++ +b); // 3

var a = 1;
var b = "2";
console.log(b + ++a); // "22"

var a = 1;
var b = "2";
console.log(+b + ++a); // 4

Symbol.toPrimitive

所以...以下bob印出來的結果就一定是[object Object]...嗎?

var bob = new Person(/* name = */ "Bob", 
                     /* birthday = */ new Date(2004/* year */, 
                                               1 /* mouth */, 
                                               1 /* day */),
                     /* addr = */ "臺灣")
console.log(`${bob}`); // 印出什麼? => [object Object]

那可不一定,現在來講Person類別多添加一個方法-- [Symbol.toPrimitive]

class Person {
    name = "";
    #birthday = new Date(); // 私有屬性
    get birthday() { return this.#birthday }; // 調整處
    addr = ""
    
    get age() {
        return new Date().getYear() - this.birthday.getYear();
    }
    
    constructor(name, birthday, addr = "") {
        this.name = name;
        this.#birthday = birthday; // 調整處
        this.addr = addr;
    }
    
    hello() {
        console.log(`Hello, ${this.name}.`);
    }
    
    [Symbol.toPrimitive](hint) {
        switch(hint) {
            case "number":
                return this.age;
            case "string":
                return `Person(name=${this.name}, age=${this.age}, addr=${this.addr})`;
            case "default":
            default:
                return this;
        }
    }
}

這一次會得到什麼結果?

var bob = new Person(/* name = */ "Bob", 
                     /* birthday = */ new Date(2004/* year */, 
                                               1 /* mouth */, 
                                               1 /* day */),
                     /* addr = */ "臺灣")
console.log(`${bob}`); // 印出什麼?

Person(name=Bob, age=18, addr=臺灣)

上面的[Symbol.toPrimitive]改成toString同樣可以做得到:

class Person {
    name = "";
    #birthday = new Date(); // 私有屬性
    get birthday() { return this.#birthday }; // 調整處
    addr = ""
    
    get age() {
        return new Date().getYear() - this.birthday.getYear();
    }
    
    constructor(name, birthday, addr = "") {
        this.name = name;
        this.#birthday = birthday; // 調整處
        this.addr = addr;
    }
    
    hello() {
        console.log(`Hello, ${this.name}.`);
    }
    
    toString() {
        return `Person(name=${this.name}, age=${this.age}, addr=${this.addr})`;
    }
}

不一樣的是,如果使用[Symbol.toPrimitive],現在可以自動轉型成數字了:

// 18歲的Bob
var bob = new Person(/* name = */ "Bob", 
                     /* birthday = */ new Date(2004/* year */, 
                                               1 /* mouth */, 
                                               1 /* day */),
                     /* addr = */ "臺灣")

// 19歲的Alice
var alice = new Person(/* name = */ "alice", 
                       /* birthday = */ new Date(2003/* year */, 
                                               1 /* mouth */, 
                                               1 /* day */),
                       /* addr = */ "臺灣")

console.log(+bob); // 18
console.log(+alice); // 19

這意味這我們可以針對兩個物件進行大小比較:

console.log(alice > bob); // true, Alice年齡比Bob大。

使用Math.max() 和 Array.prototype.sort()

Math.max()

現在雖然可以比較了,但是仍然不能使用Math.max()找到最大年紀人,因為Math.max()會嘗試將結果轉換成數字,但是可以得到最大的歲數:

var people = [
    "Alice",
    "Bob",
    "Candy",
    "Danel",
    "Frank",
    "Grant",
    "Harry",
    "Iris",
    "Joe",
    "Kevin"];

people = people.map(name => new Person(name, new Date(Math.floor(Math.random() * 100) + 1990, 1), ""))

var max_age = Math.max(...people);
console.log(max_age);

Array.prototype.sort()

此外看看Array.prototype.sort()compareFn缺省的說明:

Specifies a function that defines the sort order. If omitted, the array elements are converted to strings, then sorted according to each character's Unicode code point value.

如果沒有給compareFn,會嘗試轉換成字串做排序,而Person會被轉換成Person(name=${this.name}, age=${this.age}, addr=${this.addr}),這意味著相當於使用名字進行排序。

var people = [
    "Alice",
    "Bob",
    "Candy",
    "Danel",
    "Frank",
    "Grant",
    "Harry",
    "Iris",
    "Joe",
    "Kevin"];

people = people.map(name => new Person(name, new Date(Math.floor(Math.random() * 100) + 1990, 1), ""))
people.reverse(); // 故意先顛倒
people.sort(); // 依名字排序
console.log(people) // 變回原本順序

Sorting with map

這部分參考MDN

我們知道compareFn接收兩個參數,並返回一個數字。兩者排序完後,再進行新一輪比較。所以過程可能像是:

  1. 有 a , b, c 三個物件
  2. a 和 b 比較
    1. 將 a 轉換成字串
    2. 將 b 轉換成字串
    3. 比較兩者
  3. b 和 c 比較
    1. 將 b 轉換成字串
    2. 將 c 轉換成字串
    3. 比較兩者

這並不是一個完整的排序演算法,但重要的是b的轉換經過了兩次。如果說物件比較的對象並不是直接屬性,像是Person.age其實是計算出來的結果,要是計算在複雜一些,量在大一些,就可能需要等待一段時間才能得到結果。

一種調整方式是先將計算結果保存下來不再計算,以結果先進行排序後,在寫回原本的變數:

var people = [
    "Alice",
    "Bob",
    "Candy",
    "Danel",
    "Frank",
    "Grant",
    "Harry",
    "Iris",
    "Joe",
    "Kevin"];

people = people.map(name => new Person(name, new Date(Math.floor(Math.random() * 100) + 1990, 1), ""))


ages = people.map((v, i) => ({ i, age: v.age}));
ages.sort((a, b) => {
    if(a.age > b.age)
        return 1;
    if(a.age < b.age)
        return -1;
    return 0;
})

people = ages.map(v => people[v.i]);

null和0的比較

console.log(null == 0); // false, 兩者不相等
console.log(null >= 0); // true, 但是 null 大於等於 0
console.log(null <= 0); // true, 而且 null 小於等於 0 !?
console.log(null > 0); // false, 不過 null 既不大於 0
console.log(null < 0); // false, 而且 null 也不小於 0 ???

這裡可以分成兩種判斷:相等判斷比較判斷。除了第一個是相等判斷外,其餘的都是屬於比較判斷。

對於比較判斷,JavaScript會先嘗試將比較對象自動轉型成數字或字串。也就是說null會自動轉型成00比較,這也就說明為什麼轉型後的結果既不大於0也不小於0。

運算子多載

這樣做雖然很像是運算子多載。以Python程式碼來說很像是:

import random

class Person:
    def __init__(self, age):
        self.age = age

    def __eq__(self, other):
        return self.age == other.age

    def __gt__(self, other):
        return self.age > other.age

    def __str__(self):
        age = self.age
        return f"Person(age={age})"

    def __repr__(self):
        age = self.age
        return f"Person(age={age})"

上面Person實現了部分比較的方法,因此現在可以針對兩個物件實例直接使用比較運算子:

bob = Person(18)
alice = Person(19)
print(alice > bob) # True, alice 比 bob大

因此可以直接用於排序:

people = []
for i in range(10):
    people.append(Person(random.randint(1, 101)))
people.sort()

Java的Comparable

java並沒有多載運算子的方式,不過你可以透過實現Comparable界面或使用Comparator來進行排序。下面以Comparable為例:

import java.util.Calendar;
import java.util.ArrayList;
import java.lang.Math;
import java.util.Collections;

class Person implements Comparable<Person> {
    public String name;
    private Calendar birthday;
    public String addr;

    /* constructor */
    public Person(String name, Calendar birthday, String addr) {
        this.name = name;
        this.birthday = birthday;
        this.addr = addr;
    }

    int getAge() {
        Calendar today = Calendar.getInstance();
        int todayYear = today.get(Calendar.YEAR);
        int birthdayYear = birthday.get(Calendar.YEAR);
        return todayYear - birthdayYear;
    }

    public int compareTo(Person other) {
        int age = this.getAge();
        int otherAge = other.getAge();
        if (age == otherAge)
            return 0;
        return (this.getAge() > other.getAge()) ? 1 : -1;
    }

    public String toString() {
        return "Person(name="+name+", age="+getAge()+", addr="+addr+")";
    }
}

當類別實現了Comparable界面後,就可以使用的Collections.sort()進行排序

public class Main {
    public static void main(String[] args) {
        String[] names = {
            "Alice",
            "Bob",
            "Candy",
            "Danel",
            "Frank",
            "Grant",
            "Harry",
            "Iris",
            "Joe",
            "Kevin" };
        ArrayList<Person> people = new ArrayList<Person>();
        for(String name:names){
            Calendar cal = Calendar.getInstance();
            int birthYear = (int)Math.floor(Math.random() * 100) + 1;
            cal.set(birthYear, 1 /* mouth */, 1 /* date */);
            Person p = new Person(name, /* birthday = */cal, /* addr = */"");
            people.add(p);
        }
        System.out.println(people);
        Collections.sort(people);
        System.out.println(people);
        return;
    }
}

參考資料

本文同時發表於我的隨筆


上一篇
你可能不知道的JS物件私有屬性
下一篇
你可能不知道的HTTP Header--If-Match和該怎麼設計Web API
系列文
這些那些你可能不知道我不知道的Web技術細節33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言