簡體   English   中英

C 中數組索引(相對於表達式)的求值順序

[英]Order of evaluation of array indices (versus the expression) in C

看看這段代碼:

static int global_var = 0;

int update_three(int val)
{
    global_var = val;
    return 3;
}

int main()
{
    int arr[5];
    arr[global_var] = update_three(2);
}

哪個數組條目得到更新? 0 還是 2?

C 的規范中是否有部分指示在這種特殊情況下操作的優先級?

左右操作數的順序

要在arr[global_var] = update_three(2)執行賦值,C 實現必須評估操作數,並且作為副作用,更新左操作數的存儲值。 C 2018 6.5.16(關於賦值)第 3 段告訴我們左右操作數沒有排序:

操作數的評估是無序的。

這意味着 C 實現可以自由地首先計算左值arr[global_var] (通過“計算左值”,我們的意思是弄清楚這個表達式所指的是什么),然后計算update_three(2) ,最后分配后者對前者; 或者先評估update_three(2) ,然后計算左值,然后將前者分配給后者; 或者以某種混合方式評估左值和update_three(2) ,然后將右值分配給左左值。

在所有情況下,將值分配給左值必須放在最后,因為 6.5.16 3 還說:

… 更新左操作數的存儲值的副作用在左右操作數的值計算之后排序…

測序違規

由於同時使用global_var和單獨更新它,有些人可能會考慮未定義的行為,這違反了 6.5 2,它說:

如果對標量對象的副作用相對於對同一標量對象的不同副作用或使用同一標量對象的值進行的值計算而言是未排序的,則行為是未定義的……

許多 C 語言從業者都非常熟悉x + x++等表達式的行為並沒有在 C 標准中定義,因為它們都使用x的值並且在同一個表達式中單獨修改它而沒有排序。 但是,在這種情況下,我們有一個函數調用,它提供了一些排序。 global_vararr[global_var]使用,並在函數調用update_three(2)

6.5.2.2 10 告訴我們在函數調用之前有一個序列點:

在函數指示符和實際參數的計算之后但在實際調用之前有一個序列點......

在函數內部, global_var = val; 是一個完整的表達,因此是3return 3; , 每 6.8 4:

完整表達式是不屬於另一個表達式的一部分,也不屬於聲明符或抽象聲明符的表達式......

然后在這兩個表達式之間有一個序列點,再次按照 6.8 4:

… 在對完整表達式的求值和對下一個要求值的完整表達式的求值之間有一個序列點。

因此,C 實現可能會先評估arr[global_var] ,然后進行函數調用,在這種情況下,它們之間存在一個序列點,因為在函數調用之前有一個序列點,或者它可能評估global_var = val; 在函數調用中,然后是arr[global_var] ,在這種情況下,它們之間有一個序列點,因為在完整表達式之后有一個。 所以行為是未指定的——這兩個東西中的任何一個都可能首先被評估——但它不是未定義的。

這里的結果是不確定的

雖然決定子表達式如何分組的表達式中的操作順序已明確定義,但未指定值順序。 在這種情況下,這意味着可以先讀取global_var或首先調用update_three ,但無法知道哪個。

這里沒有未定義的行為,因為函數調用引入了一個序列點,函數中的每個語句也是如此,包括修改global_var

為了澄清起見, C 標准將第 3.4.3 節中的未定義行為定義為:

未定義的行為

在使用不可移植的或錯誤的程序結構或錯誤數據時的行為,本國際標准對此不作任何要求

並將第 3.4.4 節中未指定的行為定義為:

未指明的行為

使用未指定的值,或本國際標准提供兩種或多種可能性的其他行為,並且在任何情況下都沒有對選擇的進一步要求

標准規定函數參數的計算順序是未指定的,在這種情況下,這意味着arr[0]被設置為 3 或arr[2]被設置為 3。

我試過了,我更新了條目 0。

然而,根據這個問題: 表達式的右側是否總是首先計算

評估的順序是未指定和未排序的。 所以我認為應該避免這樣的代碼。

由於在分配值之前發出賦值代碼毫無意義,因此大多數 C 編譯器會首先發出調用函數並將結果保存在某處(寄存器、堆棧等)的代碼,然后它們會發出代碼將此值寫入其最終目的地,因此他們將在更改后讀取全局變量。 讓我們稱之為“自然秩序”,它不是由任何標准定義的,而是由純邏輯定義的。

但是在優化的過程中,編譯器會盡量去掉將值臨時存儲在某處的中間步驟,並嘗試將函數結果盡可能直接寫入最終目的地,在這種情況下,他們往往不得不先讀取索引,例如到寄存器,以便能夠直接將函數結果移動到數組中。 這可能會導致全局變量在更改之前被讀取。

所以這基本上是未定義的行為,具有非常糟糕的屬性,結果很可能會有所不同,這取決於是否執行優化以及這種優化的積極程度。 作為開發人員,您的任務是通過以下任一編碼解決該問題:

int idx = global_var;
arr[idx] = update_three(2);

或編碼:

int temp = update_three(2);
arr[global_var] = temp;

作為一個很好的經驗法則:除非全局變量是const (或者它們不是,但你知道沒有代碼會改變它們作為副作用),你不應該直接在代碼中使用它們,就像在多線程環境中一樣,即使這可以是未定義的:

int result = global_var + (2 * global_var);
// Is not guaranteed to be equal to `3 * global_var`!

由於編譯器可能會讀取它兩次並且另一個線程可以在兩次讀取之間更改值。 然而,再一次,優化肯定會導致代碼只讀取一次,所以你可能會再次得到不同的結果,這些結果現在也取決於另一個線程的時間。 因此,如果您在使用前將全局變量存儲到臨時堆棧變量中,您將少很多麻煩。 請記住,如果編譯器認為這是安全的,它很可能甚至會優化掉,而是直接使用全局變量,因此最終,它可能不會對性能或內存使用產生影響。

(以防萬一有人問為什么有人會做x + 2 * x而不是3 * x - 在某些 CPU 上,加法速度非常快,乘以 2 的冪也是如此,因為編譯器會將這些轉換為位移( 2 * x == x << 1 ),但是與任意數字的乘法可能非常慢,因此不是乘以 3,而是通過將 x 位移 1 並將 x 添加到結果中獲得更快的代碼 - 甚至該技巧由現代編譯器,如果您乘以 3 並打開積極優化,除非它是現代目標 CPU,其中乘法與加法一樣快,因為此技巧會減慢計算速度。)

全球編輯:對不起,伙計們,我被激怒了,寫了很多廢話。 只是一個老家伙咆哮。

我想相信 C 已經幸免於難,但是自 C11 以來,它已與 C++ 相提並論。 顯然,要知道編譯器將如何處理表達式中的副作用,現在需要解決一個小數學謎語,該謎語涉及基於“位於同步點之前”的代碼序列的部分排序。

在 K&R 時代,我碰巧設計並實現了一些關鍵的實時嵌入式系統(包括電動汽車的控制器,如果不檢查發動機,它可能會讓人們撞到最近的牆壁,一個 10 噸工業如果沒有正確命令,可以將人壓成一團漿糊的機器人,以及一個系統層,雖然無害,但會讓幾十個處理器以不到 1% 的系統開銷吸干他們的數據總線)。

我可能太老了或太愚蠢而無法區分未定義和未指定之間的區別,但我認為我仍然很清楚並發執行和數據訪問的含義。 在我可以說是明智的觀點中,這種對 C++ 的痴迷以及現在用他們的寵物語言接管同步問題的 C 人是一個代價高昂的白日夢。 要么您知道並發執行是什么,並且您不需要任何這些小玩意兒,要么您不需要,並且您會為整個世界提供幫助,而不是試圖弄亂它。

所有這些令人眼花繚亂的內存屏障抽象都只是由於多 CPU 緩存系統的一組臨時限制,所有這些都可以安全地封裝在常見的操作系統同步對象中,例如互斥鎖和條件變量 C++優惠。
在某些情況下,與使用細粒度的特定 CPU 指令可以實現的性能相比,這種封裝的成本只是性能的微小下降。
volatile關鍵字(或#pragma dont-mess-with-that-variable對於所有我來說,作為系統程序員,關心)已經足以告訴編譯器停止重新排序內存訪問。 可以使用直接的 asm 指令輕松生成最佳代碼,以使用特定 CPU 的特定指令散布低級驅動程序和操作系統代碼。 如果不深入了解底層硬件(緩存系統或總線接口)的工作原理,無論如何您都一定會編寫無用、低效或錯誤的代碼。

volatile關鍵字和 Bob 稍作調整,除了最頑固的低級程序員的叔叔外,每個人都可以。 取而代之的是,通常的 C++ 數學怪胎在現場設計了另一個難以理解的抽象,屈服於他們設計解決方案的典型趨勢,尋找不存在的問題,並將編程語言的定義誤認為編譯器的規范。

只是這一次改變也需要破壞 C 的一個基本方面,因為即使在低級 C 代碼中也必須生成這些“障礙”才能正常工作。 除其他外,這對表達式的定義造成了嚴重破壞,沒有任何解釋或理由。

總之,編譯器可以從這個荒謬的 C 代碼中生成一致的機器代碼這一事實只是 C++ 人員處理 2000 年代后期緩存系統潛在不一致的方式的一個遙遠的結果。
它把 C 的一個基本方面(表達式定義)搞得一團糟,以至於絕大多數 C 程序員——他們不在乎緩存系統,這是正確的——現在被迫依賴大師來解釋a = b() + c()a = b + c之間a = b() + c()區別。

無論如何,試圖猜測這個不幸的陣列會變成什么樣子都是浪費時間和精力。 不管編譯器會怎么做,這段代碼都是病態的。 唯一負責任的做法是將其發送到垃圾箱。
從概念上講,副作用總是可以從表達式中移出,只需在單獨的語句中顯式地讓修改發生在評估之前或之后。
這種糟糕的代碼在 80 年代可能是合理的,當時你不能指望編譯器優化任何東西。 但是現在編譯器早已變得比大多數程序員更聰明,剩下的只是一段糟糕的代碼。

我也無法理解這場未定義/未指定辯論的重要性。 您可以依靠編譯器生成具有一致行為的代碼,也可以不這樣做。 您是否稱其為未定義或未指定似乎是一個有爭議的問題。

在我可以說是明智的觀點中,C 在其 K&R 狀態下已經足夠危險了。 一個有用的演變是添加常識性安全措施。 例如,使用這種先進的代碼分析工具,規范強制編譯器實現至少生成關於瘋子代碼的警告,而不是默默地生成一個可能不可靠到極端的代碼。
但是他們決定,例如,在 C++17 中定義一個固定的評估順序。 現在,每個軟件白痴都被積極地煽動故意在他/她的代碼中加入副作用,相信新編譯器會以一種確定性的方式急切地處理混淆。

K&R 是計算世界真正的奇跡之一。 花 20 美元,你就得到了該語言的全面規范(我見過一個人只使用這本書編寫了完整的編譯器),一本優秀的參考手冊(目錄通常會在你的答案的幾頁內指出你問題),以及教你如何以合理的方式使用這門語言的教科書。 完整的理由、例子和明智的警告,關於你可以濫用語言來做非常非常愚蠢的事情的多種方式。

以微薄的收益摧毀遺產對我來說似乎是一種殘酷的浪費。 但同樣,我很可能無法完全理解這一點。 也許某個好心人可以為我指出一個新的 C 代碼示例的方向,該示例利用了這些副作用的顯着優勢?

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM