07/09/2018, 18:19

An explanation for JavaScript type madness

Dù bạn mới làm quen với JavaScript hay đã thân quen từ lâu thì chắc bạn cũng biết rằng JavaScript là một ngôn ngữ dynamic and wealky typed . Nó sở hữu một hệ thống typing độc nhất vô nhị. Ờ thì, khi mình nói độc nhất vô nhị thì mình không có ý nói là nó rất ngầu. Nó độc nhất ở chỗ khiến mọi người ...

Dù bạn mới làm quen với JavaScript hay đã thân quen từ lâu thì chắc bạn cũng biết rằng JavaScript là một ngôn ngữ dynamic and wealky typed. Nó sở hữu một hệ thống typing độc nhất vô nhị. Ờ thì, khi mình nói độc nhất vô nhị thì mình không có ý nói là nó rất ngầu. Nó độc nhất ở chỗ khiến mọi người phải lắm phen dở khóc dở cười. Trước khi trở thành ngôn ngữ phổ biến nhất thế giới thì typing system của JavaScript cũng đã mang lại cho nó những danh hiệu khác như là Ngôn ngữ nhiều người không hiểu nhất thế giới hay là Ngôn ngữ bị nhiều người ghét nhất thế giới.

Nếu bạn đã làm quen và ở trong cộng đồng JavaScript ít lâu chắc bạn sẽ biết đến video nổi tiếng này. Nếu chưa biết thì bạn cũng xem luôn đi. Trong đó là một vài tình huống điên cuồng nhất mà bạn có thể gặp phải với typing system của JavaScript.

tl;dw cho bạn nào lười xem

[] + [] = '
[] + {} = '[object Object]'
{} + [] = 0
{} + {} = NaN
Array(16).join('wat' - 1) + ' Batman' = 'NaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaN Batman'

Xem xong thì chắc bạn cũng đồng ý là cái typing system nó điên rồ đến mức nào rồi. Nhưng bạn có tự hỏi là đằng sau nó liệu có lí do gì không, chúng ta hãy cùng tìm hiểu trong bài này xem. Hãy thử đưa ra 1 cách giải thích logic cho những tình huống cực kì khó chịu mà bạn có thể gặp phải này. Mục đích tìm hiểu để làm gì thì ... tất nhiên là For science rồi.

Tất cả ví dụ dưới đây bạn đều có thể mở DevTool ra hoặc dùng NodeJS REPL để kiểm chứng hết nhé.

Language literals

Đầu tiên hãy nhắc lại một chút mấy cái cơ bản về typing system đã. Literal là những thứ bạn có thể declare trực tiếp trong code. Có các loại literal sau:

  • Number (e.g. 1, 3.14)
  • String (e.g. 'abc')
  • Boolean (true, false)
  • Object (e.g { a: 'b', b: 2 })
  • Array (e.g. [1, 2, 3], ['a', 'b', 'c'])
  • RegExp (e.g. /[a-zA-z0-9]/)

Các bạn mới bắt đầu với JavaScript có thể sẽ nhầm lẫn, nhưng mà mấy cái literal trên không đại diện cho các type trong JavaScript.

Primitive types

Type trong JavaScript được chia làm 2 loại primitive typeobject. Primitive type thì immutable và được pass by value còn object thì ngược lại, mutable và được pass by reference. Có các primitive type sau:

  • Boolean
  • Null
  • Undefined
  • Number
  • String
  • Symbol (ES2015)

Có thể thấy dễ dàng là trong số 6 cái literal ở trên kia thì chỉ có 3 cái là thuộc primitive type: Number, String, Boolean.

Để biết type của một giá trị thì bạn dùng typeof.

typeof 1        // number
typeof 'a'      // string
typeof true     // boolean

Vậy 3 cái còn lại hẳn typeof của nó là object rồi

typeof { name: 'John Smith' }   // object
typeof [1, 2, 3]                // object
typeof /[a-zA-Z0-9]/            // object

Vẫn còn 2 primitive type nữaundefinednull. Chắc bạn cũng không lạ gì với 2 giá trị đặc biệt null và undefined rồi. 2 cái đó cũng chính là 2 cái primitive type còn lại.

typeof undefined    // undefined

Yup, đúng rồi. Còn null nữa

typeof null         // object

thinking-at-computer

Hay lắm. Giờ thì sao null lại là object rồi.

"typeof null"

Mặc dù hầu hết mấy cái kì lạ trong bài này nhắc đến đều chẳng áp dụng vào thực tế ở đâu cả, nhưng mà cái này thì chắc cũng có nhiều bạn gặp phải và bị bất ngờ trong lần đầu tiên. Thật ra cái này là một bug đã tồn tại từ hàng chục năm nay và vẫn chưa được sửa thôi. Nhưng mà với tinh thần khoa học, chúng ta hãy thử tìm hiểu điều gì đã dẫn đến kết quả như trên xem sao.

Trong phiên bản đầu tiên của SpiderMonkey, cũng chính là JavaScript engine đầu tiên được tạo ra, các giá trị được lưu vào 1 biến 32bit. Trong đó 3 bit cuối cùng được dùng để lưu type của value đó, gọi là type tag. Các type tag tương ứng với các type như sau

  • 000: object
  • 001 hoặc 010: number
  • 100: string
  • 110: boolean

Còn 2 giá trị đặc biệt là null và undefined vì chỉ có đúng 1 giá trị nên được được gán cho 2 giá trị riêng.

  • undefined được gán cho giá trị 2-30
  • null được gán cho NULL pointer

Đến đây chắc bạn cũng đã có thể hiểu tại sao rồi. Tình cờ NULL pointer lại có giá trị binary là 0x00000000, 3 bit cuối của nó là 000, trùng với type tag của object.

Nếu bạn muốn đọc thêm nữa thì đây là đoạn code của typeof khi đó:

JS_TypeOfValue(JSContext *cx, jsval v)
{
    JSType type = JSTYPE_VOID;
    JSObject *obj;
    JSObjectOps *ops;
    JSClass *clasp;

    CHECK_REQUEST(cx);
    if (JSVAL_IS_VOID(v)) {
        type = JSTYPE_VOID;
    } else if (JSVAL_IS_OBJECT(v)) {
        obj = JSVAL_TO_OBJECT(v);
        if (obj &&
            (ops = obj->map->ops,
                ops == &js_ObjectOps
                ? (clasp = OBJ_GET_CLASS(cx, obj),
                clasp->call || clasp == &js_FunctionClass)
                : ops->call != 0)) {
            type = JSTYPE_FUNCTION;
        } else {
            type = JSTYPE_OBJECT;
        }
    } else if (JSVAL_IS_NUMBER(v)) {
        type = JSTYPE_NUMBER;
    } else if (JSVAL_IS_STRING(v)) {
        type = JSTYPE_STRING;
    } else if (JSVAL_IS_BOOLEAN(v)) {
        type = JSTYPE_BOOLEAN;
    }
    return type;
}

Có thể thấy là nó có đủ check cho undefined (JSTYPE_VOID), object (JSTYPE_OBJECT), number (JSTYPE_NUMBER), string (JSTYPE_STRING), boolean (JSTYPE_BOOLEAN). Và tất nhiên là tác giả đã quên mất đoạn check cho null. Vậy nên kết quả là typeof null === 'object'.

Như bạn đã thấy thì đây rõ ràng là một bug. Hiển nhiên là người ta đã biết đến nó từ lâu rồi. Nhưng tại sao trải qua mấy chục năm, qua biết bao JavaScript engine khác nhau, từ SpiderMoney đến Chakra rồi V8 mà nó vẫn còn tồn tại. Thì câu trả lời của tác giả là

I think it is too late to fix typeof. The change proposed for typeof null will break existing code

Đã quá trễ rồi. Kể từ sau khi Microsoft tạo ra JavaScript engine của riêng mình và copy tất cả tính năng và bug của phiên bản engine đầu tiên thì tất cả các engine về sau đều copy luôn cái bug này và giờ thì đã quá trễ để fix nó.

Bonus: ngoài ra có thể bạn cũng thắc mắc là vẫn còn 1 type khác nữa.

typeof function a(){}   // function

Thì nhân tiện có đoạn code trên, bạn có thể thấy là chỗ check JSTYPE_FUNCTION nằm chung với JSTYPE_OBJECT. Nhớ lại lúc trước chúng ta đã nói cái gì không phải primitive type thì đều là object. Nghĩa là function thật ra cũng là một object đó.

NaN is not NaN

NaN có nghĩa là Not a Number. Bạn sẽ gặp giá trị này khi cố gắng convert từ một giá trị không hợp lệ thành kiểu number

Number('a')     // NaN
parseInt('a')   // NaN

Nếu bạn đem NaN ra so sánh với nhau thì

NaN === NaN     // false
NaN !== NaN     // true

Hơi lạ phải không. NaN !== NaN có nghĩa là NaN không phải là Not a Number, vậy suy ra NaN là number. Thử xem sao

typeof NaN      // number

Yup, number. Vậy Not a Number là một number, vậy nên nó không phải là Not a Number.

thinking-at-computer

Đến đây thì tạm hết phần primitive type rồi. Các primitive type đều khá quan trọng và được dùng trong nhiều phép tính nên bạn hãy nhớ nó nhé.

null vs 0

Mình biết là mọi người thường hay dùng ! để check xem 1 giá trị có phải empty hay không. undefined, null, ' hay 0 thì đều return true cả. Có khi nhiều bạn còn xem mấy cái này là một nữa. Nhưng bạn đã thử đem chúng ra so sánh với nhau bao giờ chưa. Hãy thử so sánh null và 0 xem sao.

null > 0        // false
null < 0        // false
null == 0       // false
null >= 0       // true
null <= 0       // true

Ta có null không lớn hơn và cũng không bằng 0, nhưng mà nó lại lớn hơn hoặc bằng 0.

thinking-at-computer

Nếu bạn so sánh null và ' thì kết quả cũng tương tự. Còn đem undefined đi so sánh với bất kì cái gì trừ null cũng cho ra false hết.

Comparing values

Bây giờ hãy thử xem người ta đã làm gì để ra được kết quả kì diệu như trên. Theo EcmaScript thì phép so sánh >/< được thực hiện như sau (đã được túm gọn lại cho dễ hiểu):

Với phép so sánh a > b hoặc a < b:

  1. Convert a và b thành primitive type value bằng function ToPrimitive
  2. Nếu primitive value của cả 2 giá trị là string thì so sánh 2 string đó
  3. Nếu không thì convert primitive value của chúng thành number với function ToNumber rồi so sánh
    • Nếu 1 trong 2 số là NaN thì return false
    • Nếu không thì so sánh 2 số

Ở đây chúng ta có 2 function ToPrimitive và ToNumber

Cách tìm ToPrimitive như sau:

Function signature:

ToPrimitive(input, PreferredType?)
  1. Nếu input thuộc primitive type thì return giá trị của input luôn.
  2. Nếu input là object thì
    • Nếu giá trị của PreferredType không có thì coi như là Number
    • Nếu symbol Symbol.toPrimitive của object là function thì return value của Symbol.toPrimitive.
    • Nếu PreferredType là String thì return giá trị primitive đầu tiên của 1 trong 2 function toString hoặc valueOf của object (ưu tiên toString).
    • Nếu PreferredType là Number thì return giá trị primitive đầu tiên của 1 trong 2 function valueOf hoặc toString của object (ưu tiên valueOf).
  3. Nếu tất cả bước trên đều không thực hiện được thì throw TypeError

Để cho dễ hình dung hơn tí thì đây là function ToPrimitive. Nếu tí nữa bạn có muốn test thì có thể dùng luôn function này.

function ToPrimitive(input, PreferredType = 'number') {
    if (typeof input !== 'object' || input === null) {
        return input;
    }

    if (typeof input[Symbol.toPrimitive] === 'function') {
        return input[Symbol.toPrimitive](PreferredType);
    }

    let methodNames = [];

    if (PreferredType === 'string') {
        methodNames = ['toString', 'valueOf']
    } else if (PreferredType === 'number') {
        methodNames = ['valueOf', 'toString']
    }

    for (let method of methodNames) {
        if (typeof input[method] === 'function') {
            const value = input[method]();
            console.log(value)
            if (typeof value !== 'object' || value === null) {
                return value;
            }
        }
    }

    throw new TypeError;
}

Còn ToNumber là function để convert một giá trị thành kiểu number, bạn có thể dùng Number nếu muốn test cái này.

Number('1')     // 1

Bảng giá trị của ToNumber như sau:

Type Result
Undefined NaN
Null 0
Boolean true -> 1 false -> 0
Number Không cần convert
String Parse number
Object ToNumber(ToPrimitive(object, Number))
Symbol TypeError

Okay, vậy bây giờ thử so sánh null > 0 xem.

    null > 0
<=> ToPrimitive(null) > ToPrimitive(0)
//  Cả null và 0 đều không phải string nên
<=> ToNumber(null) > ToNumber(0)
<=> 0 > 0       // false

Còn so sánh == thì sao? Phép so sánh == được thực hiện như sau (đã túm gọn):

Với phép so sánh a == b:

  1. Nếu a và b cùng kiểu thì so sánh 2 giá trị a và b
  2. Nếu a và b khác kiểu thì
    • Nếu a = null và b = undefined hoặc ngược lại thì return false
    • Nếu 1 trong 2 giá trị là number thì và cái còn lại là string hoặc boolean thì convert cái kia thành number với ToNumber rồi so sánh.
    • Nếu một trong 2 giá trị là object và cái còn lại là string, number hoặc symbol thì convert object đó thành primitive type với ToPrimitive rồi so sánh
    • Nếu không so sánh được cái nào hết thì return false

Vậy so sánh null == 0 ta sẽ có:

  • null và 0 không cùng kiểu
  • null không phải number cũng chẳng phải string

Suy ra null != 0.

Phép so sánh a >= b và a <= b thì lại khá thú vị và logic đến không ngờ luôn

Nếu a không nhỏ hơn b thì a >= b

Nếu a không lớn hơn b thì a <= b

Ta có null < 0 == false vậy nên null >= 0. Thế thôi.

mind-blown

Adding things up

Vừa rồi là phép so sánh rồi, bây giờ đến phép cộng và trừ nhé. Hãy thử ngó qua các ví dụ trong video ở đầu bài viết. Vì các ví dụ này đều liên quan đến phép cộng nên hãy thử tìm hiểu logic của phép cộng trong JavaScript xem sao.

Theo EcmaScript thì phép cộng được thực hiện như sau (đã được tóm gọn lại cho dễ hiểu):

Với phép cộng a + b

  1. Convert a và b thành primitive type value bằng function ToPrimitive
  2. Nếu primitive value của 1 trong 2 giá trị là string thì
    • Convert 2 giá trị thành string với function ToString
    • Nối 2 string lại với nhau
  3. Nếu không thì convert 2 giá trị thành number với function ToNumber rồi cộng lại với nhau

Ta lại có thêm function mới ToString. Cũng giống như ToNumber thì bạn có thể test nó bằng function String.

String(1)     // "1"

Bảng giá trị của ToString như sau:

Type Result
Undefined 'undefined'
Null 'null'
Boolean 'true' hoặc 'false'
Number string của number e.g. 3.14 -> '3.14'
String Không cần convert
Object ToString(ToPrimitive(object, String))
Symbol TypeError

Okay lí thuyết xong rồi, giờ thử thực hành thôi.

Array plus array

[] + []

Ta có:

  [] + []
= ToPrimitive([]) + ToPrimitive([])
= ' + '
= '

Thử mở DevTool lên và gõ thử vào xem.

[] + []     // ""

Nice.

Array plus object

[] + {}

Lần này ta có:

  [] + {}
= ToPrimitive([]) + ToPrimitive({})
= ' + '[object Object]'
= '[object Object]'

Err... Ở đây có 1 cái cần chú ý đó là tại sao ToPrimitive({}) lại bằng '[object Object]' vậy? Nếu theo logic của function ToPrimitive thì bạn sẽ thấy giá trị cuối cùng của nó có vẻ sẽ là {}.toString().

Theo EcmaScript thì giá trị của Object.prototype.toString() (đã được túm gọn lại cho dễ hiểu) trừ khi object đó thuộc một trong các class Array, String, Function, Error, Boolean, Number, Date, RegExp, nếu không nó sẽ có dạng [object + className + ].

Vậy nên ta mới có ToPrimitive({}) = '[object Object]'.

Lại mở DevTool lên thử xem.

[] + {}     // "[object Object]"

Yay.

How about object plus array

Okay, mọi thứ có vẻ dễ rồi. Lúc nãy ta có [] + {} = '[object Object]' rồi, vậy theo tính chất giao hoán thì

{} + [] = '[object Object]'

Phải không.

Không ?.

{} + []     // 0

thinking-at-computer

WAT? Thử làm lại bài tập với công thức kia xem nào.

  {} + []
= ToPrimitive({}) + ToPrimitive([])
= '[object Object]' + '
= '[object Object]'

Ớ đúng mà. Ờ thì đúng nhưng mà với hầu hết JavaScript engine thì dấu { đầu tiên sẽ được xem là mở đầu của 1 code block thay vì là 1 object.

Thử cái này xem sao.

({}) + []   // "[object Object]"

Yup, vậy là công thức của chúng ta vẫn đúng. Vậy nếu cái {} đầu tiên được xem là 1 code block thì sao? Lúc này thì phép cộng trở thành +[]. Dấu cộng bây giờ được xem là một phép convert sang kiểu number. Vậy ta có:

  {} + []
= + ToPrimitive([])
= + '
= ToNumber(')
= 0

Object plus object

Okay, giờ thì đến {} + {}. Vì ta đã có kinh nghiệm từ lần trước rồi nên hi vọng bạn sẽ không nhầm nữa.

{} đầu tiên sẽ được tính là 1 code block phải không. Vậy ta có:

  {} + {}
= + ToPrimitive({})
= + '[object Object]'
= ToNumber('[object Object]')
= NaN

Yay, đúng không.

Không ?. Trừ khi bạn đang dùng DevTool của Firefox, nếu không thì kết quả bạn nhận được gần như chắc chắn sẽ là thế này.

{} + {}     // "[object Object][object Object]"

Chắc là bạn đã biết lí do rồi nhỉ. Lần này thì ngược lại, trừ Firefox DevTool ra thì khi bạn chạy cái này, các JavaScript engine đều sẽ nghĩ cái đầu tiên là 1 object. Vậy nên ta có thế này.

  {} + {}
= ToPrimitive({}) + ToPrimitive({})
= '[object Object]' + '[object Object]'
= '[object Object][object Object]'

The WATMAN

Mình sẽ copy lại ví dụ ở trên xuống đây để bạn khỏi phải scroll

Array(16).join('wat' - 1) + ' Batman' = 'NaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaN Batman'

Ở ví dụ này tác giả có thêm vài cái râu ria nhưng mà mấu chốt vấn đề chỉ là ở chỗ phép trừ 'wat' - 1 thôi. Tại sao nó lại ra NaN vậy?

Mình không muốn lại lôi ra 1 cái công thức nữa đâu, nhưng mà biết làm sao được. Nhưng bạn yên tâm, công thức lần này dễ hơn nhiều. Vậy thì đây là cách thực hiện phép trừ theo EcmaScript.

Với phép trừ a - b

  1. Convert a và b thành primitive type value bằng function ToPrimitive
  2. Convert 2 giá trị thành number với function ToNumber rồi thực hiện phép trừ

Vậy thì:

  'wat' - 1
= ToNumber(ToPrimitive('wat')) - ToNumber(ToPrimitive(1))
= ToNumber('wat') - ToNumber(1)
= NaN - 1
= NaN

Final words

Ngôn ngữ phổ biến nhất thế giớiNgôn ngữ nhiều người không hiểu nhất thế giới có vẻ là một sự kết hợp khá nguy hiểm. Mặc dù hiểu hết cái chỗ ở trên cũng không khiến bạn thành một người hiểu JavaScript, nhưng chắc bạn cũng hiểu là mọi thứ dù ngu đến thế nào đều có logic phía sau nó. Nếu lần tới bạn gặp một tình huống như thế, thì thay vì tặc lưỡi một cái vì "JavaScript nó ngu thế mà" rồi bỏ đi thì có lẽ hãy thử tìm hiểu nguồn gốc của nó xem. Hãy nghĩ đến những người viết spec đã vất vả thế nào để viết ra spec không những chạy đúng mà còn phải đảm bảo cả những trường hợp ngu si như trên cũng phải chạy đúng nữa ?. Và primitive type rất quan trọng đấy, hãy nhớ bọn nó nhé ?.

0