記一個難以發現的 UB

std發表於2023-01-03

觀察以下程式碼:

vector<int> X, Y, A, val;
inline int ls(int p) { return p << 1; }
inline int rs(int p) { return p << 1 | 1; }
int solve(int i, int l, int r) {
    if (l == r) return val[i] = A[l];
    int mid = (l + r) >> 1, p = X.size();
    X.push_back(0), Y.push_back(0);
    X[p] = solve(ls(i), l, mid);
    Y[p] = solve(rs(i), mid + 1, r);
    // do something
    return val[i];
}

這是一份標準的線段樹分治程式碼,其中陣列 \(A\) 是給定的,\(val\)\(solve\) 函式呼叫之前已經分配好了記憶體,而 \(X\)\(Y\) 的記憶體空間則是動態分配的。

當我在本地測試完整的程式碼時,不會出現任何的異常。當我將程式碼提交到學校的 OJ 上時,卻發現輸出的結果不符合預期,而且對於同樣的輸入,輸出卻和本地有所出入。

經過艱難的排查,我最終發現問題出現在了 \(solve\) 函式中,即上述程式碼的第 \(8\)\(9\) 行。我嘗試將這兩行替換為下面的程式碼:

int lp = solve(ls(i), l, mid);
X[p] = lp;
int rp = solve(rs(i), mid + 1, r);
Y[p] = rp;

這時 \(X[p]\)\(Y[p]\) 的值就從錯誤的 \(0\) 變成了正確的答案。

我不禁陷入沉思,為何看似邏輯完全相同的程式碼,產生的效果卻大相徑庭?直到我發現第 \(7\) 行程式碼中的操作:

X.push_back(0), Y.push_back(0);

有沒有可能,在第 \(8\) 行和第 \(9\) 行的賦值過程中,編譯器先對等號左邊的表示式進行計算,得到 \(X[p]\)\(Y[p]\) 的左值引用,然後再計算了等號右邊的表示式,呼叫了 \(solve\) 函式呢?

這樣一切就解釋得通了,\(X[p]\)\(Y[p]\) 的引用先被取出,然後在遞迴呼叫 \(solve\) 函式的過程中,執行到了第 \(7\) 行的 \(push\_back\) 函式,使得 \(vector\) 重新分配了堆空間,導致 \(X[p]\)\(Y[p]\) 的引用失效。於是,在賦值的過程中,我們對一個已經被釋放掉的空間進行了修改,且不說有沒有訪問到不該訪問的位置,當前 \(vector\) 中真實的 \(X[p]\)\(Y[p]\) 也沒能被賦為正確的值。

現在我們弄清楚發生 UB 的過程了。在這之後,我又進行了一些測試,目的在於弄清楚產生兩種不同情況的本質原因。繼續觀察以下程式碼:

#include <bits/stdc++.h>
using namespace std;
int func1() {
    cout << "func1" << endl;
    return 1;
}
int func2() {
    cout << "func2" << endl;
    return 2;
}
int func3() {
    cout << "func3" << endl;
    return 3;
}
struct node {
    int arr[100];
    int& operator[](int i) {
        func1();
        return arr[i];
    }
};
int main() {
    node a;
    (a[0] = func2()) = func3();
    return 0;
}

當我使用 g++ 作為編譯器,輸出結果如下:

func1
func2
func3

當我使用 clang 作為編譯器,輸出結果如下:

func3
func2
func1

歸根結底,產生這兩種區別的原因還是在於編譯器的實現。從上面的例子可以看出,g++ 在執行賦值語句的過程中,會從左往右進行運算,而 clang 則是從右往左。

在我的本機上,常用的編譯器是 apple-clang,因此上文中線段樹分治的程式碼從右往左執行賦值操作,不會產生引用失效的問題。而學校 OJ 的預設編譯器為 g++,自然就出現與預期相違的情況了。

個人認為,對於這兩種執行順序,應當是從右往左更加符合正常人的邏輯,畢竟如 A = B = C 這樣的連續賦值語句也是從右往左執行的。

總而言之,為了不觸發此類未定義行為,在寫程式碼時還需要多注意一下。對於本文開頭的例子,最好還是在呼叫 \(solve\) 函式之前先對 \(X\)\(Y\) 的記憶體空間進行 \(reserve\),這樣就不會在 \(push\_back\) 時出現引用失效的問題了。

相關文章