Đã tràn tràn đã ký trong C ++ và hành vi không xác định (UB)


56

Tôi đang tự hỏi về việc sử dụng mã như sau

int result = 0;
int factor = 1;
for (...) {
    result = ...
    factor *= 10;
}
return result;

Nếu vòng lặp được lặp lại theo nthời gian, thì factorđược nhân với số lần 10chính xác n. Tuy nhiên, factorchỉ được sử dụng sau khi đã được nhân lên 10tổng số n-1lần. Nếu chúng ta giả sử rằng factorkhông bao giờ tràn ra ngoại trừ lần lặp cuối cùng của vòng lặp, nhưng có thể tràn vào lần lặp cuối cùng của vòng lặp, thì mã đó có được chấp nhận không? Trong trường hợp này, giá trị của chứng minh factorsẽ không bao giờ được sử dụng sau khi xảy ra tràn.

Tôi đang có một cuộc tranh luận về việc liệu mã như thế này có nên được chấp nhận hay không. Có thể đặt phép nhân bên trong một câu lệnh if và chỉ không thực hiện phép nhân trên lần lặp cuối cùng của vòng lặp khi nó có thể tràn. Nhược điểm là nó khóa mã và thêm một nhánh không cần thiết sẽ cần kiểm tra trên tất cả các lần lặp lại trước đó. Tôi cũng có thể lặp lại vòng lặp một lần nữa và sao chép thân vòng lặp một lần sau vòng lặp, điều này lại làm phức tạp mã.

Mã thực tế trong câu hỏi được sử dụng trong một vòng lặp bên trong chặt chẽ, tiêu tốn một lượng lớn tổng thời gian CPU trong một ứng dụng đồ họa thời gian thực.


5
Tôi đang bỏ phiếu để đóng câu hỏi này ngoài chủ đề vì câu hỏi này phải có trên codereview.stackexchange.com không có ở đây.
Kevin Anderson

31
@KevinAnderson, không có giá trị ở đây, vì mã ví dụ sẽ được sửa chữa, không chỉ được cải thiện.
Bathsheba


1
@LightnessRaceswithMonica: Các tác giả của Standard dự định và dự kiến ​​rằng việc triển khai dành cho các nền tảng và mục đích khác nhau sẽ mở rộng ngữ nghĩa có sẵn cho các lập trình viên bằng cách xử lý một cách có ý nghĩa các cách khác nhau hữu ích cho các nền tảng đó và mục đích cho dù Tiêu chuẩn có yêu cầu họ làm như vậy hay không, và cũng tuyên bố rằng họ không muốn hạ thấp mã không di động. Do đó, sự giống nhau giữa các câu hỏi phụ thuộc vào việc triển khai nào cần hỗ trợ.
supercat

2
@supercat Đối với các hành vi được xác định theo triển khai chắc chắn và nếu bạn biết chuỗi công cụ của mình có một số tiện ích mở rộng bạn có thể sử dụng (và bạn không quan tâm đến tính di động), tốt thôi. Dành cho UB? Nghi ngờ.
Các cuộc đua nhẹ nhàng trong quỹ đạo

Câu trả lời:


51

Trình biên dịch giả định rằng một chương trình C ++ hợp lệ không chứa UB. Xem xét ví dụ:

if (x == nullptr) {
    *x = 3;
} else {
    *x = 5;
}

Nếu x == nullptrsau đó hủy bỏ nó và gán một giá trị là UB. Do đó cách duy nhất điều này có thể kết thúc trong một chương trình hợp lệ là khi nào x == nullptrsẽ không bao giờ mang lại kết quả đúng và trình biên dịch có thể giả sử theo quy tắc như thể, ở trên là tương đương với:

*x = 5;

Bây giờ trong mã của bạn

int result = 0;
int factor = 1;
for (...) {      // Loop until factor overflows but not more
   result = ...
   factor *= 10;
}
return result;

Phép nhân cuối cùng của factorkhông thể xảy ra trong một chương trình hợp lệ (tràn đã ký không được xác định). Do đó, việc chuyển nhượng resultkhông thể xảy ra. Vì không có cách nào phân nhánh trước lần lặp cuối cùng nên lần lặp trước đó không thể xảy ra. Cuối cùng, phần mã chính xác (nghĩa là không có hành vi không xác định nào từng xảy ra) là:

// nothing :(

6
"Hành vi không xác định" là một biểu thức chúng ta nghe rất nhiều trong các câu trả lời SO mà không giải thích rõ ràng làm thế nào nó có thể ảnh hưởng đến toàn bộ chương trình. Câu trả lời này làm cho mọi thứ rõ ràng hơn rất nhiều.
Gilles-Philippe Paillé

1
Và điều này thậm chí có thể là một "tối ưu hóa hữu ích" nếu chức năng chỉ được gọi trên các mục tiêu với INT_MAX >= 10000000000, với một chức năng khác được gọi trong trường hợp INT_MAXnhỏ hơn.
R .. GitHub DỪNG GIÚP ICE

2
@ Gilles-PhilippePaillé Có những lúc tôi ước chúng ta có thể stickey một bài đăng trên đó. Benign Data Races là một trong những mục yêu thích của tôi để nắm bắt mức độ khó chịu của chúng. Ngoài ra còn có một báo cáo lỗi tuyệt vời trong MySQL mà tôi dường như không thể tìm thấy nữa - một kiểm tra tràn bộ đệm đã vô tình gọi ra UB. Một phiên bản cụ thể của trình biên dịch cụ thể chỉ đơn giản là giả sử UB không bao giờ xảy ra và tối ưu hóa toàn bộ kiểm tra tràn.
Cort Ammon

1
@SolomonSlow: Các tình huống chính mà UB gây tranh cãi là những tình huống mà các phần của tài liệu Tiêu chuẩn và triển khai mô tả hành vi của một số hành động, nhưng một số phần khác của Tiêu chuẩn mô tả nó là UB. Cách làm thông thường trước khi Tiêu chuẩn được viết là dành cho các nhà văn trình biên dịch xử lý các hành động đó một cách có ý nghĩa trừ khi khách hàng của họ sẽ được hưởng lợi từ việc họ làm gì đó và tôi không nghĩ rằng các tác giả của Tiêu chuẩn đã tưởng tượng rằng các nhà văn biên dịch sẽ cố tình làm bất cứ điều gì khác .
supercat

2
@ Gilles-PhilippePaillé: Điều mà mọi lập trình viên C nên biết về hành vi không xác định từ blog LLVM cũng tốt. Nó giải thích làm thế nào ví dụ UB tràn số nguyên có thể cho phép trình biên dịch chứng minh rằng i <= ncác vòng lặp luôn không phải là vô hạn, giống như i<ncác vòng lặp. Và quảng bá int iđến chiều rộng con trỏ trong một vòng lặp thay vì phải làm lại dấu để có thể lập chỉ mục mảng cho các thành phần mảng 4G đầu tiên.
Peter Cordes

34

Hành vi của inttràn là không xác định.

Không có vấn đề gì nếu bạn đọc factorbên ngoài cơ thể vòng lặp; nếu sau đó nó đã bị tràn thì hành vi của mã của bạn trên, sau đó và hơi nghịch lý trước khi tràn không được xác định.

Một vấn đề có thể phát sinh trong việc giữ mã này là các trình biên dịch ngày càng trở nên tích cực hơn khi tối ưu hóa. Cụ thể, họ đang phát triển một thói quen nơi họ cho rằng hành vi không xác định không bao giờ xảy ra. Đối với trường hợp này, họ có thể loại bỏ forhoàn toàn vòng lặp.

Bạn không thể sử dụng một unsignedloại cho factordù sau đó bạn sẽ cần phải lo lắng về chuyển đổi không mong muốn của intđể unsignedtrong các biểu thức có chứa cả hai?


12
@nicomp; Tại sao không?
Bathsheba

12
@ Gilles-PhilippePaillé: Không phải câu trả lời của tôi cho bạn biết đó là vấn đề sao? Câu mở đầu của tôi không nhất thiết phải có cho OP, nhưng cộng đồng rộng hơn Và factorđược "sử dụng" trong bài tập trở lại chính nó.
Bathsheba

8
@ Gilles-PhilippePaillé và câu trả lời này giải thích tại sao nó có vấn đề
idclev 463035818

1
@Bathsheba Bạn nói đúng, tôi đã hiểu nhầm câu trả lời của bạn.
Gilles-Philippe Paillé

4
Như một ví dụ về hành vi không xác định, khi mã đó được biên dịch với kiểm tra thời gian chạy được kích hoạt, nó sẽ chấm dứt thay vì trả về kết quả. Mã yêu cầu tôi tắt các chức năng chẩn đoán để làm việc bị hỏng.
Simon Richter

23

Nó có thể là sâu sắc để xem xét tối ưu hóa trong thế giới thực. Unrolling vòng là một kỹ thuật được biết đến. Ý tưởng cơ bản op loop unrolling là

for (int i = 0; i != 3; ++i)
    foo()

có thể được thực hiện tốt hơn đằng sau hậu trường như

 foo()
 foo()
 foo()

Đây là trường hợp dễ dàng, với một ràng buộc cố định. Nhưng trình biên dịch hiện đại cũng có thể làm điều này cho các giới hạn khác nhau:

for (int i = 0; i != N; ++i)
   foo();

trở thành

__RELATIVE_JUMP(3-N)
foo();
foo();
foo();

Rõ ràng điều này chỉ hoạt động nếu trình biên dịch biết rằng N <= 3. Và đó là nơi chúng ta trở lại câu hỏi ban đầu. Bởi vì trình biên dịch biết rằng tràn tràn đã ký không xảy ra , nó biết rằng vòng lặp có thể thực thi tối đa 9 lần trên các kiến ​​trúc 32 bit. 10^10 > 2^32. Do đó, nó có thể thực hiện một vòng lặp 9 lần lặp. Nhưng tối đa dự định là 10 lần lặp! .

Điều có thể xảy ra là bạn có được một bước nhảy tương đối đến một lệnh lắp ráp (9-N) với N = 10, do đó, độ lệch là -1, chính là lệnh nhảy. Giáo sư. Đây là một tối ưu hóa vòng lặp hoàn toàn hợp lệ cho C ++ được xác định rõ, nhưng ví dụ đưa ra biến thành một vòng lặp vô hạn chặt chẽ.


9

Bất kỳ tràn số nguyên đã ký nào đều dẫn đến hành vi không xác định, bất kể giá trị tràn đó có hoặc có thể được đọc hay không.

Có thể trong trường hợp sử dụng của bạn, bạn có thể nhấc lần lặp đầu tiên ra khỏi vòng lặp, biến điều này

int result = 0;
int factor = 1;
for (int n = 0; n < 10; ++n) {
    result += n + factor;
    factor *= 10;
}
// factor "is" 10^10 > INT_MAX, UB

vào đây

int factor = 1;
int result = 0 + factor; // first iteration
for (int n = 1; n < 10; ++n) {
    factor *= 10;
    result += n + factor;
}
// factor is 10^9 < INT_MAX

Khi tối ưu hóa được kích hoạt, trình biên dịch có thể hủy bỏ vòng lặp thứ hai ở trên thành một bước nhảy có điều kiện.


6
Điều này có thể là một chút quá kỹ thuật, nhưng "tràn đã ký là hành vi không xác định" là quá mức. Chính thức, hành vi của một chương trình với tràn tràn đã ký là không xác định. Đó là, tiêu chuẩn không cho bạn biết chương trình đó làm gì. Không phải chỉ có điều gì đó không ổn với kết quả tràn ra; có gì đó không đúng với toàn bộ chương trình.
Pete Becker

Quan sát công bằng, tôi đã sửa câu trả lời của mình.
elbrunovsky

Hay đơn giản hơn, bóc lớp lặp cuối cùng và loại bỏ người chếtfactor *= 10;
Peter Cordes

9

Đây là UB; trong thuật ngữ ISO C ++, toàn bộ hành vi của toàn bộ chương trình hoàn toàn không được chỉ định cho một thực thi cuối cùng đạt được UB. Ví dụ kinh điển là theo tiêu chuẩn C ++, nó có thể khiến quỷ bay ra khỏi mũi bạn. (Tôi khuyên bạn không nên sử dụng một triển khai trong đó quỷ mũi là một khả năng thực sự). Xem câu trả lời khác để biết thêm chi tiết.

Trình biên dịch có thể "gây rắc rối" tại thời gian biên dịch cho các đường dẫn thực thi mà chúng có thể thấy dẫn đến UB biên dịch theo thời gian biên dịch, ví dụ giả sử các khối cơ bản đó không bao giờ đạt được.

Xem thêm Những gì mỗi lập trình viên C nên biết về hành vi không xác định (blog LLVM). Như đã giải thích ở đó, UB tràn đã ký cho phép trình biên dịch chứng minh rằng for(... i <= n ...)các vòng lặp không phải là các vòng lặp vô hạn, ngay cả khi chưa biết n. Nó cũng cho phép họ "quảng bá" bộ đếm vòng lặp int thành chiều rộng con trỏ thay vì làm lại phần mở rộng dấu hiệu. (Vì vậy, hậu quả của UB trong trường hợp đó có thể là truy cập bên ngoài các yếu tố 64k hoặc 4G thấp của một mảng, nếu bạn đang mong đợi việc đóng gói có chữ ký ivào phạm vi giá trị của nó.)

Trong một số trường hợp, trình biên dịch sẽ phát ra một lệnh bất hợp pháp như x86 ud2cho một khối có thể gây ra UB nếu được thực thi. (Lưu ý rằng một hàm có thể chưa bao giờ được gọi, do đó, trình biên dịch nói chung không thể đi berserk và phá vỡ các hàm khác, hoặc thậm chí các đường dẫn có thể thông qua một hàm không nhấn UB. Tức là mã máy mà nó biên dịch vẫn phải hoạt động tất cả các yếu tố đầu vào không dẫn đến UB.)


Có lẽ giải pháp hiệu quả nhất là tự bóc lớp lặp cuối cùng để không cần thiết factor*=10có thể tránh được.

int result = 0;
int factor = 1;
for (... i < n-1) {   // stop 1 iteration early
    result = ...
    factor *= 10;
}
 result = ...      // another copy of the loop body, using the last factor
 //   factor *= 10;    // and optimize away this dead operation.
return result;

Hoặc nếu thân vòng lặp lớn, hãy xem xét đơn giản bằng cách sử dụng loại không dấu cho factor. Sau đó, bạn có thể để tràn bội số không dấu và nó sẽ chỉ thực hiện gói được xác định rõ với một số lũy thừa là 2 (số bit giá trị trong loại không dấu).

Điều này tốt ngay cả khi bạn sử dụng nó với các loại đã ký, đặc biệt là nếu chuyển đổi chưa ký-> đã ký của bạn không bao giờ tràn.

Chuyển đổi giữa phần bổ sung không dấu và 2 được ký là miễn phí (cùng mẫu bit cho tất cả các giá trị); gói modulo cho int -> không dấu được chỉ định bởi tiêu chuẩn C ++ đơn giản hóa việc chỉ sử dụng cùng một mẫu bit, không giống như bổ sung hoặc ký hiệu / cường độ của một người.

Và unsign-> đã ký là tương tự tầm thường, mặc dù nó được xác định theo triển khai cho các giá trị lớn hơn INT_MAX. Nếu bạn không sử dụng kết quả không dấu lớn từ lần lặp cuối cùng, bạn không có gì phải lo lắng. Nhưng nếu bạn là, hãy xem Có phải chuyển đổi từ không dấu sang ký không xác định? . Trường hợp giá trị không phù hợp được xác định theo thực thi , có nghĩa là việc triển khai phải chọn một số hành vi; những người lành mạnh chỉ cắt bớt (nếu cần) mẫu bit không dấu và sử dụng nó như đã ký, bởi vì nó hoạt động cho các giá trị trong phạm vi theo cùng một cách mà không cần làm thêm. Và nó chắc chắn không phải là UB. Vì vậy, các giá trị không dấu lớn có thể trở thành số nguyên ký âm. ví dụ: sau khi int x = u; gcc và clang không tối ưu hóa đix>=0như luôn luôn đúng, thậm chí không có -fwrapv, bởi vì họ xác định hành vi.


2
Tôi không hiểu downvote ở đây. Tôi chủ yếu muốn đăng bài về lột lần lặp cuối cùng. Nhưng để trả lời câu hỏi, tôi đã tập hợp một số điểm về cách mò mẫm UB. Xem câu trả lời khác để biết thêm chi tiết.
Peter Cordes

5

Nếu bạn có thể chịu đựng một vài hướng dẫn lắp ráp bổ sung trong vòng lặp, thay vì

int factor = 1;
for (int j = 0; j < n; ++j) {
    ...
    factor *= 10;
}

bạn có thể viết:

int factor = 0;
for (...) {
    factor = 10 * factor + !factor;
    ...
}

để tránh sự nhân lên cuối cùng. !factorsẽ không giới thiệu một chi nhánh:

    xor     ebx, ebx
L1:                       
    xor     eax, eax              
    test    ebx, ebx              
    lea     edx, [rbx+rbx*4]      
    sete    al    
    add     ebp, 1                
    lea     ebx, [rax+rdx*2]      
    mov     edi, ebx              
    call    consume(int)          
    cmp     r12d, ebp             
    jne     .L1                   

Mã này

int factor = 0;
for (...) {
    factor = factor ? 10 * factor : 1;
    ...
}

cũng dẫn đến lắp ráp không phân nhánh sau khi tối ưu hóa:

    mov     ebx, 1
    jmp     .L1                   
.L2:                               
    lea     ebx, [rbx+rbx*4]       
    add     ebx, ebx
.L1:
    mov     edi, ebx
    add     ebp, 1
    call    consume(int)
    cmp     r12d, ebp
    jne     .L2

(Được biên dịch với GCC 8.3.0 -O3)


1
Đơn giản hơn để chỉ bóc vòng lặp cuối cùng, trừ khi thân vòng lặp lớn. Đây là một cách hack thông minh nhưng làm tăng độ trễ của chuỗi phụ thuộc mang theo vòng lặp thông qua factormột chút. Hoặc không: khi nó biên dịch thành 2 lần LEA, nó sẽ hiệu quả tương đương với LEA + ADD để làm f *= 10như vậy f*5*2, với testđộ trễ được ẩn trước LEA. Nhưng nó có chi phí rất cao trong vòng lặp nên có thể có nhược điểm thông lượng (hoặc ít nhất là vấn đề thân thiện với siêu văn hóa)
Peter Cordes

4

Bạn đã không hiển thị những gì trong ngoặc đơn của fortuyên bố, nhưng tôi sẽ cho rằng nó giống như thế này:

for (int n = 0; n < 10; ++n) {
    result = ...
    factor *= 10;
}

Bạn có thể chỉ cần di chuyển kiểm tra gia tăng bộ đếm và chấm dứt vòng lặp vào cơ thể:

for (int n = 0; ; ) {
    result = ...
    if (++n >= 10) break;
    factor *= 10;
}

Số lượng hướng dẫn lắp ráp trong vòng lặp sẽ giữ nguyên.

Lấy cảm hứng từ bài thuyết trình của Andrei Alexandrescu "Tốc độ được tìm thấy trong tâm trí mọi người".


2

Hãy xem xét chức năng:

unsigned mul_mod_65536(unsigned short a, unsigned short b)
{
  return (a*b) & 0xFFFFu;
}

Theo Lý do xuất bản, các tác giả của Tiêu chuẩn dự kiến sẽ có rằng nếu chức năng này được gọi vào (ví dụ) một phổ biến máy tính 32-bit với lập luận của 0xC000 và 0xC000, thúc đẩy các toán hạng của *để signed intcó thể gây ra việc tính toán để mang lại -0x10000000 , mà khi được chuyển đổi unsignedsẽ mang lại 0x90000000u- câu trả lời tương tự như thể họ đã unsigned shortquảng bá tới unsigned. Tuy nhiên, đôi khi gcc sẽ tối ưu hóa chức năng đó theo những cách sẽ hành xử vô nghĩa nếu xảy ra tràn. Bất kỳ mã nào trong đó một số kết hợp đầu vào có thể gây ra tràn phải được xử lý bằng -fwrapvtùy chọn trừ khi có thể chấp nhận cho phép người tạo đầu vào không đúng định dạng thực thi mã tùy ý mà họ chọn.


1

Tại sao không phải là điều này:

int result = 0;
int factor = 10;
for (...) {
    factor *= 10;
    result = ...
}
return result;

Điều đó không chạy ...thân vòng lặp cho factor = 1hoặc factor = 10, chỉ 100 và cao hơn. Bạn sẽ phải bóc lớp lặp đầu tiên vẫn bắt đầu với factor = 1nếu bạn muốn nó hoạt động.
Peter Cordes

1

Có nhiều khuôn mặt khác nhau của Hành vi không xác định và những gì được chấp nhận tùy thuộc vào cách sử dụng.

vòng lặp bên trong chặt chẽ tiêu tốn một lượng lớn tổng thời gian CPU trong một ứng dụng đồ họa thời gian thực

Điều đó, tự nó, là một điều hơi bất thường, nhưng vì nó có thể ... nếu đây thực sự là trường hợp, thì UB có lẽ là trong phạm vi "cho phép, chấp nhận được" . Lập trình đồ họa khét tiếng với những vụ hack và những thứ xấu xí. Miễn là nó "hoạt động" và không mất nhiều hơn 16,6ms để tạo ra một khung, thông thường, không ai quan tâm. Tuy nhiên, hãy lưu ý đến ý nghĩa của việc gọi UB.

Đầu tiên, có tiêu chuẩn. Từ quan điểm đó, không có gì để thảo luận và không có cách nào để biện minh, mã của bạn chỉ đơn giản là không hợp lệ. Không có ifs hay whens, nó không phải là một mã hợp lệ. Bạn cũng có thể nói rằng đó là ngón tay giữa từ quan điểm của bạn, và 95-99% thời gian bạn sẽ vẫn tốt để đi.

Tiếp theo, có phần cứng. Có một số kiến trúc không phổ biến, kỳ lạ , đây là một vấn đề. Tôi đang nói "không phổ biến, kỳ lạ" bởi vì trên một kiến trúc chiếm tới 80% tất cả các máy tính (hoặc hai kiến trúc cùng nhau chiếm tới 95% tất cả các máy tính) là một "ừ, sao cũng được, đừng quan tâm" điều ở cấp độ phần cứng. Bạn chắc chắn nhận được một kết quả rác (mặc dù vẫn có thể dự đoán được), nhưng không có điều gì xấu xảy ra.
Đó không phảitrường hợp trên mọi kiến ​​trúc, rất có thể bạn sẽ mắc bẫy tràn (mặc dù nhìn cách bạn nói về một ứng dụng đồ họa, khả năng có một kiến ​​trúc kỳ quặc như vậy là khá nhỏ). Là tính di động là một vấn đề? Nếu có, bạn có thể muốn kiêng.

Cuối cùng, có trình biên dịch / tối ưu hóa. Một lý do tại sao tràn không được xác định là chỉ đơn giản là để nó ở đó là dễ dàng nhất để đối phó với phần cứng một lần. Nhưng một lý do khác là ví dụ x+1được đảm bảo luôn lớn hơn xvà trình biên dịch / trình tối ưu hóa có thể khai thác kiến ​​thức này. Bây giờ, đối với trường hợp đã đề cập trước đó, trình biên dịch thực sự được biết là hành động theo cách này và chỉ đơn giản là loại bỏ các khối hoàn chỉnh (đã tồn tại một khai thác Linux vài năm trước, dựa trên trình biên dịch đã loại bỏ một số mã xác thực vì chính xác điều này).
Đối với trường hợp của bạn, tôi thực sự nghi ngờ rằng trình biên dịch thực hiện một số tối ưu hóa đặc biệt, kỳ quặc. Tuy nhiên, những gì bạn biết, những gì tôi biết. Khi nghi ngờ, hãy thử nó. Nếu nó hoạt động, bạn tốt để đi.

(Và cuối cùng, có khóa học kiểm toán mã, bạn có thể phải lãng phí thời gian để thảo luận điều này với kiểm toán viên nếu bạn không may mắn.)

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.