循環是所有程式語言中最重要的機制之一,幾乎任何擁有實際意義的電腦程式(排序、查詢等)都裡不開循環。 而循環也正是程序優化中非常讓人頭痛的一環,我們往往需要不斷去優化程序的複雜度,卻因循環而糾結在時間複雜度與空間複雜度之間的抉擇。
在 javascript 中,有3種原生循環,for () {}, while () {}和do {} while (),其中最為常用的要數for () {}。
然而for正是 javascript 工程師在最佳化程式時最容易忽略的一種循環。
我們先來回顧for的基本知識。
javascript 的for語法繼承自c語言,for迴圈的基本語法有兩種使用方法。
1. 循環數組
for循環的基本語法
我們以一段實例程式碼來進行詳細說明。
for (var i = 0, len = array.length; i sum = array[i];
}
console.log('The sum of the array's items is %d.', sum);
//=> The sum of the array's items is 15.
在這段程式碼中,我們先定義並初始化了一個用儲存待累加項的陣列和一個總和整形變數。 接下來,我們開始進行迴圈。在該for迴圈的初始化程式碼中,我們也定義並初始化了兩個變數: i(計數器)和len(迴圈數組長度的別名),當i小於len時,迴圈條件成立,執行邏輯程式碼;每次邏輯程式碼執行完畢以後,i自增1。
在迴圈的邏輯程式碼中,我們把目前迴圈的陣列項加到總和變數中。
這個循環用流程圖表示為如下:
從這個流程圖中我們不難發現,程式中真正的循環體不僅有我們的邏輯程式碼,還包含了實現循環自身的執行判斷和循環處理。
這樣,我們的最佳化思路就清晰了,我們可以從四個方面進行最佳化。
1.循環體前的初始化代碼
2.循環體中的執行判斷條件
3.邏輯代碼
4.邏輯代碼後的處理代碼
ps: 其中第一點和第二點有重要關係。
1.1 最佳化初始化程式碼與執行判斷條件
我們先來看看一段大家都很熟悉的程式碼。
1.初始化程式碼 - 這段迴圈只定義並初始化了一個計數器變數。
2.執行判斷條件 - 當計數器小於list的長度時成立。
3.處理代碼 - 計數器自增1。
我們再回顧一下上面的流程圖,發現有什麼倪端沒?
真正的循環體不只有我們的邏輯程式碼,還包含了實作循環本身的執行判斷和處理程式碼。 也就是說,i 似乎明白了點什麼了吧?這個判斷條件有兩個操作:1. 從list數組中查詢length屬性;2. 比較i與list.length的大小。
假設list數組含有 n 個元素,則程式需要在這個循環的執行判斷中進行 2n 次操作。
如果我們把程式碼改成這樣:
在這段改進後的程式碼中,我們在循環體執行前的初始化程式碼中, 增加定義並初始化了一個len變量,用於儲存list.length的值(關於變數、表達式、指標和值的相關內容將在第二篇討論)。 這樣,我們在循環體中的執行判斷中就無需再次對list數組進行屬性查詢,操作數為原先的一半。
以上步驟我們完善了演算法的時間複雜度,而如果要繼續優化空間複雜度的話,要如何做呢? 如果你的邏輯程式碼不受循環順序限制,那你可以嘗試以下最佳化方式。
這段程式碼透過把循環順序倒置,把i計數器從最後一個元素下標(list.length - 1)開始,向前循環。 以達到把迴圈所需變數數減到 1 個,而且在執行判斷中,降低了變數查詢的次數,減少了執行 cpu 指令前的耗時。
1.2 最佳化邏輯程式碼
在迴圈中,我們得到循環目前的陣列元素自然是為了對其或利用其進行一些操作,這不免會出現對該元素數次的呼叫。
for (var i = array.length - 1; i >= 0; --i) {
console.log('Name: %s', array[i].name);
console .log('He/She is a(n) %s', array[i].type);
console.log('rn');
}
/*=>
Name: Vill Lin
He/She is a(n) moegril
Name: Will Wen Gunn
He/She is a(n) hentai
*/
這段程式碼中,程式需要對每個陣列元素的name和type屬性進行查詢。 如果陣列有 n 個元素,程式就進行了 4n 次物件查詢。
];
var person = null;
console.log('Name: %s', person. name );
console.log('He/She is a(n) %s', person.type);
console.log('rn');
有點像 emcascript5 中的foreach,不過這兩者之間差別狠大,這裡不多做解釋。
ps:感謝大家的指正,經過實驗得知,如果數組內的元素是直接傳值定義的,則在循環中得到值一定是值,而非指針。 所以無論是定義表達式還是變量,都會有額外的記憶體空間請求。
1.3 最佳化處理程式碼
ps:如果有什麼好的建議或方法,歡迎提供。 :)
2. 循環物件(object)在 javascript 中,for還可以對 object 的屬性和方法進行歷遍。 需要注意的是,for迴圈無法對物件所屬的包裝類型或是建構函數中原型屬性、方法(prototype)進行歷遍。
語法比循環數組還要簡單。
我們常常這個方法來進行對物件的操作。
for (var key in person) {
// if the value is array, convert it to a string
if (value instanceof Array) {
value = value.join(', ');
console.log('%s: %s', key, value);
}
/*=>
name: Will Wen Gunn
type: hentai
skill: Programming, Photography, Speaking, etc
如果你曾經使用過 mongodb,那你對它的 query 機制絕對不會陌生。 因為 mongodb 的 query 機制就像是它的 api 的靈魂,靈活的 curd 操作方式為 mongodb 贏得了不少人氣和發展動力。
而在 nanodb 的 mongo api 實作中,query 的實作方式就大面積地使用了循環物件。
var _cursor = myColl.find({
type : 'repo',
language : 'JavaScript'
_cursor
.sort({
star: 1
})
.toArray(function(err, rows) { err);
});
就比如說 nanodb 中的 nanocollection 類,雖然表面看上去就是一個數組, 存有所有的元素,或是一個對象,用元素的 id 作為鍵,然後對元素進行存儲。
console.log(_inverted);
/*=>
{
'Will Wen Gunn' : 'name', 'hentai' : 'type'
}
*/
var _inverted = _.invert(person);
if (_inverted[名稱] === '名稱') {
console.log('Catched!');}
//=> Catched!
接下來我們來看看其他兩個循環,while () {}和do {} while ()。 相信任何接收過電腦科學課程的朋友都這兩個循環都不會陌生。他們唯一的差異就在與執行循環體的邏輯順序。
while () {}的執行順序與for () {}類似,執行判斷在邏輯程式碼之前,不過省去了初始化和處理程式碼。
當給予的條件時,便執行邏輯程式碼,直到條件不再成立。
while (sum sum = sum 1;
console.log(sum);
//=> 15
do {} while ()則是把執行判斷放到了邏輯代碼之後,也就是「先斬後奏」。
程式碼如下:
do {
sum = sum 1;
} while (sum
console.log(sum);
//=> 15
while () {}與do {} while ()同樣不需要計數器,而是透過某些條件來判斷是否執行或繼續執行邏輯程式碼。
3. while () {}和do {} while ()
while () {}和do {} while ()主要用於業務邏輯中,為達到某一目的而不斷執行一系列操作,如任務隊列。
但這兩個迴圈是危險的,因為它們預設只受執行條件的控制,如果一旦邏輯程式碼內一直沒有對執行判斷產生任何影響,就會出現死迴圈。
// warning!
while (sum sum = 1 12
}
這樣的程式碼無異於while (true) {},所以在使用之前,必須先明確執行條件和如何對執行條件產生影響的方法。
4. 善用迴圈控制語句
相信所有 javascript 工程師都使用過break語句,但continue語句卻相對少用。 實際上,在不少優秀的 javascript 開源專案中,都能發現continue的身影。
為了了解continue語句的作用,我們還是先來看看一段實例程式碼
var var = require('net');
var util = require('util');
var broadcastServer = net.createServer();
// Client Store
broadcastServer.clients = [];
// Clients Broadcast Method
net.Socket.prototype.broadcast = function(msg) {
// 取得發佈廣播的客戶端在集合中的下標
var index = clients.indexOf(this);
for (var i = clients.length - 1; i >= 0; --i) {
if (i === index) {
continue;
}
currClient = clients[i];
if (!currClient.destroyed) {
currClient.write(
,
currClient.remoteAddress , currClient.remotePort, msg)
);
}
};
// A new client connected
broadcastServer.clients.push(client);
// Welcome
client.broadcast(client, 'Joined!');
// Message handle
client.on('data', function(msg) {
client.broadcast(msg);
);
// Disconnect handle
client.on('end', function() {
client.broadcast('Left!');
});
);
})
});
這段程式碼基於node.js 的net模組實作了一個broadcast server,在其中的broadcast方法中,我們使用了continue語句, 用以實現將資訊向除發布廣播的客戶端外的所有已建立連接的客戶端。
代碼內容相當簡單, 當某一客戶端需要向其他客戶端發布廣播時,則呼叫該客戶端所對應client對象的broadcast方法, 在broadcast方法中,程式會先獲取當前客戶端在以緩存的客戶端socket 集合中的位置下標, 然後對所有客戶端socket 進行循環發布,當循環計數器來到之前獲得的位置下標,則跳過當前循環體中的邏輯代碼,繼續下一個循環。
相信學過 c/c 語言的工程師都會從各種地方得到這樣一個忠告:「不要使用 goto 語句。」
而這個「臭名昭著」的goto語句其實就是一個程式碼流程控制器,關於goto語句的詳細內容這裡不會詳細說明。 然而 javascript 沒有明顯的goto語句,但從break語句和continue語句中,不難發現 javascript 中goto的影子。
這是因為break語句和continue語句允許接受由一個定義好的 label 名稱,以進行程式碼跳躍。
我們來看看 mdn 提供的實例程式碼。
loop1:
for (i = 0; i loop2:
for (j = 005; j ) { //The second for statement is labeled "loop2"
if (i == 1 && j == 1) {
console.log (" i = " i ", j = " j);
}
}
}
// Output is:
// "i = 0, j = 1"
// "i = 0, j = 2"
// "i = 1, j = 0"
// "i = 2, j = 0"
// "i = 2, j = 1"
// "i = 2, j = 2"
// Notice how it skips both "i = 1, j = 1" and "i = 1, j = 2"
第一層循環在loop1的 label 中,也就是說在後面的程式中,如果在continue語句或break語句選擇了loop1 label,就會跳出最外層循環。
第二層循環在頂層循環中的loop2的 label 中,若在continue語句或break語句中選擇了loop2 label,就會回到頂層循環的循環體內。
透過使用循環控制語句,我們可以對原有的循環執行判斷進行干涉,以至於可以建構出十分複雜的邏輯系統。 說句題外話,linux kernel 中有非常多的goto語句,至於為什麼還是能經常聽到不要用goto語句之流的言論,就自己 google 吧。
5.1 展開循環
我們先來看看兩段程式碼,你猜猜哪一個的效能比較。
// 情況1
for (var i = array.length - 1; i >= 0; i--) {
for (var j = array[i].length - 1; j > = 0; i--) {
process(array[i][j]);
}
}
// 情況2
for (var i = array.length - 1; i >= 0; i = i - 4) {
for (var j = array[i].length - 1 ; j >= 0; j = j - 6) {
過程(array[i][j]);
製程(array[i][j - 1]);
過程(array[i][j - 1]);
過程(array [y ][j - 2]);
process(array[i][j - 3]);
process(array[i][j - 4]);
[j - 5]);
}
for (var j = array[i - 1].length - 1; j >= 0; j = j - 6) {
process(array [i ][j]);
process(array[i][j - 1]);
process(array[i][j - 2]);
process(array[i][ j -array[i][ j - 3]);
process(array[i][j - 4]);
process(array[i][j - 5]);
}
for (var j = array[ i - 2].length - 1; j >= 0; j = j - 6) {
process(array[i][j]);
process(array[i][j - 1]) ;
process(array[i][j - 2]);
process(array[i][j - 3]);
process(array[i][j - 4] );
process(array[i][j - 5]);
}
for (var j = array[i - 3].length - 1; j >= 0; j = j - 6) {
process(array[i][j]);
process(array[i][j - 1]);
process(array[i][j - 2]); (array[i][j - 3]);
process(array[i][j - 4]);
process(array[i][j - 5]);
}
}
這裡我們來看看一個更給力的解決方案。如果一個業務場景中需要對大數據集進行迭代處理,而這個資料集從開始迭代開始,資料量不會再改變,那麼可以考慮採用一種稱為 duff 裝置的技術。這項技術是調理的創造者 tom duff 的名字來命名的,類似技術最先在 c 語言中實現。後來 jeff greenberg 將其移植到 javascript 中,並經過 andrew b 。 king 修改並提出了一個更有效率的版本。
if (leftover > 0) {
do {
process(values[i ]);
} while (--leftover > 0);
}
} while (--leftover > 0);
}
do {
do { > process(values[i ]);
process(values[i ]);
process(values[i ]);
process(values[i ]);
process(values) [ i ]);
process(values[i ]);
process(values[i ]);
process(values[i ]);
這種技術的工作原理是透過計算值的長度除以8來得到需要迭代的次數,並用math.floor()函數來保證結果為整數,然後再計算不能是8整除時的餘數,把這些元素單獨進行處理,其餘則8 次為單次展開次數來進行迭代。
我將這種裝置加上封裝,可以獲得一個帶有標籤的非同步味道的 api。
程式碼如下: var i = 0;
if (l > 0) {
do {
mapper(array[i ]);
mapper(array[i ]);
mapper(array[i ]);
mapper(array[i ]);
}
do {
mapper(array[i ]);
mapper(array[i ]);
mapper(array[i ]);
mapper(array[i ]);
mapper(array[i ]);
mapper(array[i ]);
--n > 0);
duff([...], function(item) {
//...
});
這裡是一組對於以上三種迭代解決方案的效能測試及其結果。 http://jsperf.com/spreaded-loop
5.2 非原生循環
在任何程式語言中,能夠實現循環的,不只語言所提供的原生循環語句,還可以透過其他方式來間接實現。
讓我們先來溫習一下高中數學的一點內容-數列的通項公式。
程式碼如下:
so
a[n] 1 = 2 * a[n - 1] = 2 * (a[n - 1] 1)
(a[n] 1) / (a [n - 1] 1) = 2
a[n] 1 = (a[n] 1) / (a[n - 1] 1) * (a[n - 1] 1) / (a[n - 2] 1) * ... * (a[2] 1) / (a[1] 1) * (a[i] 1)
a[n] 1 = 2 * 2 * ... * 2 * 2
a[n] 1 = 2^n
a[n] = 2^n - 1
a[n] = 2^n - 1
遞歸是數學和電腦科學中非常重要的一種應用方法,它是指函數在使用時調用其自身。
在 node.js 社群中,遞歸被用來實現一種非常重要的技術:中介軟體技術。 這是一段尚未公佈的新版本的 webjs 中的中間件實作程式碼。
var middlewares = this._usingMiddlewares;
// 執行下一個中間件(如果存在)
function next(err) {
index ;
// 目前中間件
var curr = middlewares[index];
if (curr) {
var check = new RegExp(curr.route);
// 檢視路線
if (check.test(url)) {
try { // 依賴項現在還沒有
_later(curr);
index --;
next();
} else {
// 此中間零件無法運作
}
if (utils.isFunc(curr.handler)) {
// 普通中間件函數
curr.handler(req, res, next, later)
;
;
;
;
;
;;
;
;
; } else if (utils.isObject(curr.handler) && utils.isFunc(curr.handler.emit)) {
curr.handler.emit('request', req, res, next, la curr.handler.emit('request', req, res, next, later);
} 其他 {
// 中間件有問題
以 next();
}
} catch(err) {
next();
}
// 進入管線的下一步
out();
}
}
// 如果中介軟體依賴其他中間件,
// 可以讓它稍後運作
function _later(curr) {
var i = middlewares.indexOf(curr);
var _tmp1 = middlewares.slice(0, i);
var _tmp2 = middles.slice(iware 2); 中介軟體= _tmp1;
}
// 第一個中間件
next();
};
var middlewares = this._usingMiddlewares;
// run the next middleware if it is exists
function next(err) {
index ;
// current middleware
var curr = middlewares[index];
if (curr) {
var check = new RegExp(curr.route);
// Check the route
);
} else {
next();
}
} else {
// Out to next step the p. 🎜> }
// first middleware
next();
return this;
遞迴之所以可以用於中介軟體系統的實現,是因為遞歸是最適合 node.js 中非同步 i/o 的程式流程回應方式。
在這段中間件實作程式碼中,this._usingmiddlewares為循環數組,function next()是循環體,其中check.test(url)為執行判斷條件, 而循環處理程式碼就是循環體中最前面的index計數器自增1 和next函數自身的遞歸呼叫。