從規範看賦值表示式的解析

mg20發表於2018-11-18

從一道常見的面試題開始:

var a = {n: 1
};
var b = a;
a.x = a = a.y = {n: 2
};
console.log(a.x);
console.log(b.y);
複製程式碼

顯然,關鍵點在於最後一個語句的執行。這個語句的執行主要涉及了 屬性獲取表示式賦值表示式,先去規範裡看對於這兩種語法及其執行的規定。

1. 賦值表示式

規範中規定了三種形式的賦值表示式:

AssignmentExpression :  ConditionalExpression LeftHandSideExpression = AssignmentExpression  LeftHandSideExpression AssignmentOperator AssignmentExpression複製程式碼

a.x = a = a.y = {n: 2
};
是其中的第二種形式 (第三種形式中的AssignmentOperator在規範中是複合賦值符號,即 += 等等)。 有的同學說,js中=是從右向左執行的。對於語句的執行,規範中寫道:

The source text of an ECMAScript program is first converted into a sequence of input elements, which are tokens, line terminators, comments, or white space. The source text is scanned from left to right, repeatedly taking the longest possible sequence of characters as the next input element.

也就是說,原始碼被轉換為一系列的輸入單元(輸入單元的型別包括token,行結束符,註釋和空白符);
然後從左到右進行解析,重複以最長子序列作為下一個輸入單元。除此之外,規範規定了每種型別語句的執行流程,卻並沒有地方提到 = 要從右向左執行。造成這種廣泛的誤解的,可能是類似 MDN 在語句優先順序的地方提到了 =Associativity是從右到左,但其實這個Associativity並不是執行流程。

規範中規定了表示式 AssignmentExpression : LeftHandSideExpression = AssignmentExpression 的執行流程(11.13.1節中),我們把這個流程命名為 parseAssignment, 後面會以 parseAssignment(n)來指代執行這裡的第n步:

The production AssignmentExpression : LeftHandSideExpression = AssignmentExpression is evaluated as follows:1. Let lref be the result of evaluating LeftHandSideExpression.2. Let rref be the result of evaluating AssignmentExpression.3. Let rval be GetValue(rref).4. Throw a SyntaxError exception if the following conditions are all true:  Type(lref) is Reference is true  IsStrictReference(lref) is true  Type(GetBase(lref)) is Environment Record  GetReferencedName(lref) is either "eval" or "arguments"5. Call PutValue(lref, rval).6. Return rval.複製程式碼

顯然,第一步是 evaluating LeftHandSideExpression ,將結果賦給變數 lref 。然後是 evaluating AssignmentExpression, 將結果付給 rref。那麼在表示式 a.x = a = a.y = {n: 2
}
中 哪一部分是 LeftHandSideExpression, 哪一部分是 rref 有沒有疑問呢?會不會a.x = a 或者 a.x = a = a.yLeftHandSideExpression 呢?

再來看 LeftHandSideExpression 的語法:

LeftHandSideExpression :  NewExpression  CallExpression複製程式碼

只有這兩種形式,它們具體的語法定義我們就不翻了,不然可能會翻出10多層(事實上,規範中正是通過這種巢狀的表示式語法定義,規定了其優先順序)。總之沒有賦值表示式,並沒有涉及到 = 語法。

且規範中規定了語句解析順序是從左到右(Chapter 7),所以 a.x = a = a.y = {n: 2
};
中的 LeftHandSideExpression 就是 a.x

再仔細思考 AssignmentExpression : LeftHandSideExpression = AssignmentExpression, 把最後的 AssignmentExpression置換為左邊的 AssignmentExpression,就得到了我們使用的這個表示式 : AssignmentExpression : LeftHandSideExpression = (LeftHandSideExpression = AssignmentExpression)。從這裡我們也能看出,對於a.x = a = a.y = {n: 2
};
的執行來說,是先把 a.x 當作 LeftHandSideExpression,把a = a.y = {n: 2
}
當作 AssignmentExpression;執行到 evaluating AssignmentExpression時,再把 a 當作 LeftHandSideExpressiona.y = {n: 2
}
作為 AssignmentExpression。直到最後以 a.y 作為 LeftHandSideExpression, 以 {n: 2
}
作為AssignmentExpression(AssignmentExpression的第一種形式ConditionalExpression是允許為 物件字面量 的)。

按照這樣的執行步驟,第一步就是把 執行 a.x 的結果賦給 lrefa.x是一個 屬性讀取表示式,我們再來看它的執行流程。

【Note】規範中並沒有對優先順序進行規定,只是通過設定語句的解析規則,形成了事實上的優先順序。讀者可以試試這段程式碼的結果: var a = "a" console.log(a) // 'a' true ? a : a = 'c' console.log(a) // 'a' false ? a : a = "c" console.log(a) // 'c'若按照優先順序規定,條件表示式的優先順序高於賦值表示式;那麼語句應該按照 先執行條件表示式,後執行賦值表示式的順序執行,第二個輸出就應該是'c'了。但事實上是'a'。這是因為按照規範的表示式解析規則,=的左邊總是被解析為 LeftHandSideExpression,而條件表示式並不在它的語法形式之中。所以按照最大可解析長度的原則,上式被解析為了true ? a : (a = 'c'),所以只有在最後 a 才會被改寫為'c'。複製程式碼

2. 屬性獲取表示式

規範中在 LeftHandSideExpression 相關 Property Accessors(11.2.1節) 中規定了其執行流程,我們把這個流程命名為 parseMember, 後面會以 parseMember(n)來指代執行這裡的第n步:

The production MemberExpression : MemberExpression [ Expression ] is evaluated as follows:1. Let baseReference be the result of evaluating MemberExpression.2. Let baseValue be GetValue(baseReference).3. Let propertyNameReference be the result of evaluating Expression.4. Let propertyNameValue be GetValue(propertyNameReference).5. Call CheckObjectCoercible(baseValue).6. Let propertyNameString be ToString(propertyNameValue).7. If the syntactic production that is being evaluated is contained in strict mode code, let strict be true, else letstrict be false.8. Return a value of type Reference whose base value is baseValue and whose referenced name ispropertyNameString, and whose strict mode flag is strict.複製程式碼

我們看到這個流程大概是,從 MemberExpression (即這裡的 a) 得到baseValue, 從 Expression (即這裡的字串 x )得到 propertyNameString,然後返回以它們組成的 Reference。我們先去了解下 Reference

3. Reference

規範的第8章 Types 中, 將型別分為兩大類: 一是語言型別,也就是提供給開發者的Undefined, Null, Boolean, String, Number, and Object;另一類是 規範型別,它們不會提供給開發者,也不一定對應到一個es實現中的資料結構,只是用來描述規範中的演算法和剛才提到的語言型別,可以理解為是用來描述演算法和資料結構的抽象。Reference 就是規範型別的一種。

規範的8.7節中這樣寫到:

A Reference is a resolved name binding. A Reference consists of three components, the base value, the referenced name and the Boolean valued strict reference flag. The base value is either undefined, an Object, a Boolean, a String, a Number, or an environment record (10.2.1). A base value of undefined indicates that the reference could not be resolved to a binding. The referenced name is a String.

意即,Reference 是一個 已解析的命名繫結。所謂命名繫結,就是說它用來用一個命名找到對應的某個內部數值/資料;所謂已解析,就是說這個 命名 到 資料 的繫結關係是確定的。好比我們在面對函式中的某個變數,想要知道它的確切值是多少,就是想確定它的命名繫結。簡而言之,Reference 就是一個表示引用型別或者環境物件的抽象。一個 Reference 由三個部分組成: basereference namestrict flag

base可以看作是就是引用的實體或作宿主,好比 a.x 就是一個引用,它的 base value就是 areference name 則是字串 x。而在如下函式func中:

function func() { 
var a = 'a';

}複製程式碼

a 也是一個引用,它的 basefunc 函式對應的執行環境的環境記錄(Enviroment Record);
reference name則是字串 'a'

前述表示式的執行流程中還用到了 ReferenceGetValue 方法。我們看它的執行過程:

GetValue (V)1. If Type(V) is not Reference, return V.2. Let base be the result of calling GetBase(V). // 獲取 Reference 的 base component3. If IsUnresolvableReference(V), throw a ReferenceError exception.4. If IsPropertyReference(V), thena. If HasPrimitiveBase(V) is false, then let get be the [[Get]] internal method of base, otherwise let get be the special [[Get]] internal method defined below.b. Return the result of calling the get internal method using base as its this value, and passing GetReferencedName(V) for the argument.5. Else, base must be an environment record.a. Return the result of calling the GetBindingValue (see 10.2.1) concrete method of base passingGetReferencedName(V) and IsStrictReference(V) as arguments.複製程式碼

即,如果引數 V 不是一個 Reference 型別,那麼直接返回;否則在 base上取出對應 reference name的值並返回。

4. 題目分析

有了這些基礎,我們可以來分析面試題中的表示式了。步驟如下:

  1. 執行parseAssignment(1), 即執行 a.x 表示式,將得到的 Reference 型別值賦給 lrefa.x 是一個 Property Accessor,我們來按照規範解析它的執行:

     1.1 parseMember(1). MemberExpression 是 a。這是表示式 PrimaryExpression 的 Identifier 型別,它會返回一個 Reference 型別的值: base 是全域性環境變數(global enviroment record),reference name是'a',strict flag是false。  1.2 parseMember(2). 對全域性環境變數呼叫 GetBindingValue('a')方法,在變數物件中找到對應的值,即 a 所引用的 物件字面量 {n: 1
    }。 1.3 parseMember(3). Let propertyNameReference = 'x' 1.4 parseMember(4). Let propertyNameValue = 'x' 1.5 parseMember(5). 檢查是否可以1.2中的返回值是否可以轉為 Object, {n: 1
    }本就是物件型別,返回true 1.6 parseMember(6). 獲取property name string,即'x' 1.7 parseMember(7). 設定 strict flag 為false 1.8 parseMember(8). 返回一個 Reference 型別的值,base 是 {n: 1
    }, reference name是'x', strict flag 是 false。複製程式碼

    這裡第一步執行完得到的 lref 就是1.8中返回的值。

  2. parseAssignment(2). 執行 a = a.y = {n: 2
    },將返回值賦給 rref。它的執行如下:

     2.1 執行 a。它返回一個 Reference 型別的值,base 是 全域性環境變數,refrence name是'a', strict flag是false。我們姑且稱這一步的lref為 lref2.1。 2.2 執行 a.y = {n: 2
    }。它也是一個賦值表示式,執行如下: 2.2.1 執行 a.y 。這裡又涉及到了對 a 的解析,前面的操作並沒有改變 a 的引用,所以到現在為止,a 仍然會被解析為全域性環境變數上的一個命名繫結。所以對 a.y 的解析所返回的 Reference 中,base 元件是就是lref中的base。 我們姑且稱這一步的lref為 lref2.2.1,它的組成: base 是 {n: 1
    },refrence name是'y', strict flag是false。(注意 lref2.2.1 的 base 與 lref 的 base, 是同一個物件。因為 a 都會解析為 全域性環境變數 上對應屬性'a'的物件。) 2.2.2 parseAssignment(2). 這裡右邊是一個 物件初始化表示式,返回一個物件型別的值 {n: 2
    }。 2.2.3 parseAssignment(3). 對上一步中的返回值執行 GetValue(rref),結果仍然是 {n: 2
    }, 賦給 rval2.2.3。 2.2.4 parseAssignment(4). 判斷是否拋異常,這裡不會。 2.2.5 parseAssignment(5). 呼叫 PutValue(lref2.2.1, rval2.2.3),結果是lref2.2.1 的base增加了一個屬性,此時變為了 {n: 1, y: {n: 2
    }
    } // 這裡的 base 與 lref 中的 base 仍然是同一個物件 2.2.6 parseAssignment(6). 返回 rval2.2.3。 所以這一步返回 rval2.2.3。 2.3 parseAssignment(3). 對2.2返回的值進行 GetValue(rref), 仍然是 rval2.2.3 2.4 parseAssignment(4). 判斷是否要拋異常,這裡不會。 2.5 parseAssignment(5). 呼叫 PutValue(lref2.1, rval2.2.3),lref2.1 的base是 全域性環境變數,這裡修改了其中變數 a 的引用,指向新的物件 rval2.2.3 2.6 parseAssignment(6). 返回 rval2.2.3。複製程式碼

    這一步的返回仍然是物件 rval2.2.3。

  3. parseAssignment(3). 將 rval 設為上一步的返回即 rval2.2.3。

  4. parseAssignment(4). 判斷是否要拋異常,這裡不會。

  5. parseAssignment(5). 呼叫 PutValue(lref, rval),lref 的base 增加了一個屬性,此時變為了 {n: 1, y: {n: 2
    }, x: {n: 2
    }
    }

  6. Return rval.

所以執行完後,變數 a 所引用的物件是 {n: 2
}。 而它之前指向的物件,也即這時變數b指向的物件(b 的指向未改變過),變為了 {n: 1, y: {n: 2
}, x: {n: 2
}
}
。可以用 JSON.stringify 驗證下b。而且這時候 b.xb.y 和 a 指向同一個物件。

其實這裡的關鍵點就是,賦值表示式要先對左邊的表達進行引用確定,再進行賦值。

這樣走完一遍,應該再也不怕面試官問你賦值表示式了吧~

PS: 文中對於符號優先順序的闡述,完全出於自己對規範的理解,歡迎小夥伴們指正:D

PPS: 經樓下同學指出,貼出文章所參照的規範地址EcmaScript 5.1 Edition。文章參照的是舊版本,es6之後對於這個問題的分析是一致的,差異主要是:

  • 表示式增加了更多語法。比如es6中賦值表示式增加了對箭頭函式和Yeild語法的支援。
  • 對應的章節不同。比如es6中表示式放到了第12章,對Reference的闡述放在了6.2.3(The Reference Specification Type ),關於輸入原始碼解析的機制放在了第11章(ECMAScript Language: Lexical Grammar)中。

來源:https://juejin.im/post/5bf15e4bf265da6142737ec0

相關文章