Khai báo các biến bên trong các vòng lặp, thực hành tốt hay thực hành xấu?


265

Câu hỏi số 1: Khai báo một biến trong vòng lặp là một thực tiễn tốt hay thực tiễn xấu?

Tôi đã đọc các chủ đề khác về việc có hay không có vấn đề về hiệu năng (hầu hết nói không) và rằng bạn phải luôn luôn khai báo các biến gần với nơi chúng sẽ được sử dụng. Điều tôi băn khoăn là liệu điều này có nên tránh hay không nếu nó thực sự được ưa thích.

Thí dụ:

for(int counter = 0; counter <= 10; counter++)
{
   string someString = "testing";

   cout << someString;
}

Câu hỏi số 2: Có phải hầu hết các trình biên dịch nhận ra rằng biến đã được khai báo và chỉ cần bỏ qua phần đó, hoặc nó thực sự tạo ra một điểm cho nó trong bộ nhớ mỗi lần?


29
Đặt chúng gần với cách sử dụng của chúng, trừ khi hồ sơ nói khác đi.
Vịt Mooing

1
Dưới đây là một số câu hỏi tương tự: stackoverflow.com/questions/982963/
Thẻ

3
@drnewman Tôi đã đọc những chủ đề đó, nhưng họ không trả lời câu hỏi của tôi. Tôi hiểu rằng khai báo các biến trong vòng lặp hoạt động. Tôi tự hỏi liệu đó có phải là một thực hành tốt để làm như vậy hoặc nếu đó là điều cần tránh.
JeramyRR

Câu trả lời:


348

Đây là thực hành tuyệt vời .

Bằng cách tạo các biến bên trong các vòng lặp, bạn đảm bảo phạm vi của chúng được giới hạn bên trong vòng lặp. Nó không thể được tham chiếu hay gọi là bên ngoài vòng lặp.

Cách này:

  • Nếu tên của biến là một chút "chung chung" (như "i"), không có rủi ro để trộn nó với một biến khác có cùng tên ở đâu đó trong mã của bạn (cũng có thể được giảm thiểu bằng cách sử dụng -Wshadowhướng dẫn cảnh báo trên GCC)

  • Trình biên dịch biết rằng phạm vi biến được giới hạn bên trong vòng lặp, và do đó sẽ đưa ra một thông báo lỗi thích hợp nếu biến bị lỗi do tham chiếu ở nơi khác.

  • Cuối cùng nhưng không kém phần quan trọng, một số tối ưu hóa chuyên dụng có thể được trình biên dịch thực hiện hiệu quả hơn (quan trọng nhất là đăng ký phân bổ), vì nó biết rằng biến không thể được sử dụng bên ngoài vòng lặp. Ví dụ, không cần lưu trữ kết quả để sử dụng lại sau này.

Tóm lại, bạn có quyền làm điều đó.

Tuy nhiên, lưu ý rằng biến không được phép giữ lại giá trị của nó giữa mỗi vòng lặp. Trong trường hợp như vậy, bạn có thể cần phải khởi tạo nó mỗi lần. Bạn cũng có thể tạo một khối lớn hơn, bao gồm vòng lặp, với mục đích duy nhất là khai báo các biến phải giữ giá trị của chúng từ vòng này sang vòng khác. Điều này thường bao gồm chính bộ đếm vòng lặp.

{
    int i, retainValue;
    for (i=0; i<N; i++)
    {
       int tmpValue;
       /* tmpValue is uninitialized */
       /* retainValue still has its previous value from previous loop */

       /* Do some stuff here */
    }
    /* Here, retainValue is still valid; tmpValue no longer */
}

Đối với câu hỏi số 2: Biến được phân bổ một lần, khi hàm được gọi. Trong thực tế, từ góc độ phân bổ, nó (gần như) giống như khai báo biến ở đầu hàm. Sự khác biệt duy nhất là phạm vi: biến không thể được sử dụng bên ngoài vòng lặp. Thậm chí có thể là biến không được phân bổ, chỉ cần sử dụng lại một số vị trí miễn phí (từ biến khác có phạm vi đã kết thúc).

Với phạm vi hạn chế và chính xác hơn đến tối ưu hóa chính xác hơn. Nhưng quan trọng hơn, nó làm cho mã của bạn an toàn hơn, với ít trạng thái hơn (tức là các biến) để lo lắng khi đọc các phần khác của mã.

Điều này đúng ngay cả bên ngoài một if(){...}khối. Thông thường, thay vì:

    int result;
    (...)
    result = f1();
    if (result) then { (...) }
    (...)
    result = f2();
    if (result) then { (...) }

viết an toàn hơn:

    (...)
    {
        int const result = f1();
        if (result) then { (...) }
    }
    (...)
    {
        int const result = f2();
        if (result) then { (...) }
    }

Sự khác biệt có vẻ nhỏ, đặc biệt là trên một ví dụ nhỏ như vậy. Nhưng trên cơ sở mã lớn hơn, nó sẽ giúp: bây giờ không có rủi ro để vận chuyển một số resultgiá trị từ f1()để f2()chặn. Mỗi cái resultđược giới hạn nghiêm ngặt trong phạm vi riêng của nó, làm cho vai trò của nó chính xác hơn. Từ góc độ người đánh giá, nó đẹp hơn nhiều, vì anh ta có ít biến số trạng thái dài hơn để lo lắng và theo dõi.

Ngay cả trình biên dịch sẽ giúp tốt hơn: giả sử rằng, trong tương lai, sau một số thay đổi mã sai, resultkhông được khởi tạo đúng cách với f2(). Phiên bản thứ hai đơn giản sẽ từ chối hoạt động, nêu rõ thông báo lỗi rõ ràng tại thời gian biên dịch (cách tốt hơn thời gian chạy). Phiên bản đầu tiên sẽ không phát hiện ra bất cứ điều gì, kết quả của việc f1()đơn giản sẽ được kiểm tra lần thứ hai, bị nhầm lẫn cho kết quả của f2().

Thông tin bổ sung

Công cụ nguồn mở CppCheck (một công cụ phân tích tĩnh cho mã C / C ++) cung cấp một số gợi ý tuyệt vời về phạm vi tối ưu của các biến.

Đáp lại nhận xét về phân bổ: Quy tắc trên là đúng trong C, nhưng có thể không dành cho một số lớp C ++.

Đối với các kiểu và cấu trúc tiêu chuẩn, kích thước của biến được biết tại thời điểm biên dịch. Không có thứ gọi là "xây dựng" trong C, vì vậy không gian cho biến sẽ được phân bổ đơn giản vào ngăn xếp (không có bất kỳ khởi tạo nào), khi hàm được gọi. Đó là lý do tại sao có chi phí "không" khi khai báo biến trong vòng lặp.

Tuy nhiên, đối với các lớp C ++, có điều xây dựng này mà tôi biết ít hơn nhiều. Tôi đoán phân bổ có lẽ sẽ không phải là vấn đề, vì trình biên dịch sẽ đủ thông minh để sử dụng lại cùng một không gian, nhưng việc khởi tạo có thể sẽ diễn ra ở mỗi lần lặp.


4
Câu trả lời tuyệt vời. Đây chính xác là những gì tôi đang tìm kiếm, và thậm chí còn cho tôi cái nhìn sâu sắc về điều mà tôi không nhận ra. Tôi đã không nhận ra rằng phạm vi chỉ còn trong vòng lặp. Cảm ơn bạn đã phản hồi!
JeramyRR

22
"Nhưng nó sẽ không bao giờ chậm hơn việc phân bổ ở đầu hàm." Điều này không phải lúc nào cũng đúng. Biến sẽ được phân bổ một lần, nhưng nó vẫn sẽ được xây dựng và phá hủy nhiều lần nếu cần. Mà trong trường hợp mã ví dụ, là 11 lần. Để trích dẫn nhận xét của Mooing "Đặt chúng gần với cách sử dụng của chúng, trừ khi hồ sơ nói khác đi."
IronMensan

4
@JeramyRR: Hoàn toàn không - trình biên dịch không có cách nào để biết liệu đối tượng có tác dụng phụ có ý nghĩa trong hàm tạo hoặc hàm hủy của nó hay không.
ildjarn

2
@Iron: Mặt khác, khi bạn khai báo mục đầu tiên, bạn chỉ nhận được nhiều cuộc gọi đến toán tử gán; mà chi phí thường giống như xây dựng và phá hủy một đối tượng.
Billy ONeal

4
@BillyONeal: Đối với stringvectorcụ thể, toán tử gán có thể sử dụng lại bộ đệm được phân bổ mỗi vòng lặp, điều này (tùy thuộc vào vòng lặp của bạn) có thể là một khoản tiết kiệm thời gian rất lớn.
Vịt Mooing

22

Nói chung, đó là một thực hành rất tốt để giữ cho nó rất gần.

Trong một số trường hợp, sẽ có một sự xem xét như hiệu suất biện minh cho việc kéo biến ra khỏi vòng lặp.

Trong ví dụ của bạn, chương trình tạo và hủy chuỗi mỗi lần. Một số thư viện sử dụng tối ưu hóa chuỗi nhỏ (SSO), do đó, có thể tránh phân bổ động trong một số trường hợp.

Giả sử bạn muốn tránh những sáng tạo / phân bổ dư thừa đó, bạn sẽ viết nó dưới dạng:

for (int counter = 0; counter <= 10; counter++) {
   // compiler can pull this out
   const char testing[] = "testing";
   cout << testing;
}

hoặc bạn có thể kéo hằng số ra:

const std::string testing = "testing";
for (int counter = 0; counter <= 10; counter++) {
   cout << testing;
}

Có phải hầu hết các trình biên dịch nhận ra rằng biến đã được khai báo và chỉ cần bỏ qua phần đó, hoặc nó thực sự tạo ra một điểm cho nó trong bộ nhớ mỗi lần?

Nó có thể sử dụng lại không gian mà biến tiêu thụ và nó có thể kéo bất biến ra khỏi vòng lặp của bạn. Trong trường hợp mảng const char (ở trên) - mảng đó có thể được kéo ra. Tuy nhiên, hàm tạo và hàm hủy phải được thực thi tại mỗi lần lặp trong trường hợp của một đối tượng (chẳng hạn như std::string). Trong trường hợp std::string, 'không gian' đó bao gồm một con trỏ chứa phân bổ động đại diện cho các ký tự. Vậy đây:

for (int counter = 0; counter <= 10; counter++) {
   string testing = "testing";
   cout << testing;
}

sẽ yêu cầu sao chép dự phòng trong từng trường hợp và phân bổ động và miễn phí nếu biến nằm trên ngưỡng cho số ký tự SSO (và SSO được thư viện std của bạn triển khai).

Làm điều này:

string testing;
for (int counter = 0; counter <= 10; counter++) {
   testing = "testing";
   cout << testing;
}

vẫn sẽ yêu cầu một bản sao vật lý của các ký tự ở mỗi lần lặp, nhưng biểu mẫu có thể dẫn đến một phân bổ động vì bạn gán chuỗi và việc triển khai sẽ thấy không cần thay đổi kích thước phân bổ sao lưu của chuỗi. Tất nhiên, bạn sẽ không làm điều đó trong ví dụ này (vì nhiều lựa chọn thay thế ưu việt đã được chứng minh), nhưng bạn có thể xem xét nó khi nội dung của chuỗi hoặc vectơ thay đổi.

Vậy bạn sẽ làm gì với tất cả những lựa chọn đó (và hơn thế nữa)? Giữ nó rất gần như một mặc định - cho đến khi bạn hiểu rõ chi phí và biết khi nào bạn nên đi chệch hướng.


1
Về các kiểu dữ liệu cơ bản như float hoặc int, việc khai báo biến bên trong vòng lặp sẽ chậm hơn khai báo biến đó bên ngoài vòng lặp vì nó sẽ phải phân bổ một khoảng trống cho biến mỗi lần lặp?
Kasparov92

2
@ Kasparov92 Câu trả lời ngắn gọn là "Không. Bỏ qua việc tối ưu hóa đó và đặt nó vào vòng lặp khi có thể để cải thiện khả năng đọc / địa phương. Trình biên dịch có thể thực hiện tối ưu hóa vi mô đó cho bạn." Chi tiết hơn, cuối cùng là để trình biên dịch quyết định, dựa trên những gì tốt nhất cho nền tảng, mức tối ưu hóa, v.v. Một int / float thông thường bên trong một vòng lặp thường sẽ được đặt trên ngăn xếp. Một trình biên dịch chắc chắn có thể di chuyển ra bên ngoài vòng lặp và sử dụng lại bộ lưu trữ nếu có tối ưu hóa trong việc đó. Với mục đích thực tế, đây sẽ là một tối ưu hóa rất rất nhỏ
justin

1
@ Kasparov92 Gặp (tiếp) mà bạn chỉ xem xét trong các môi trường / ứng dụng có tính chu kỳ. Trong trường hợp đó, bạn có thể muốn xem xét sử dụng lắp ráp.
justin

14

Đối với C ++, nó phụ thuộc vào những gì bạn đang làm. OK, đó là mã ngu ngốc nhưng hãy tưởng tượng

class myTimeEatingClass
{
 public:
 //constructor
      myTimeEatingClass()
      {
          sleep(2000);
          ms_usedTime+=2;
      }
      ~myTimeEatingClass()
      {
          sleep(3000);
          ms_usedTime+=3;
      }
      const unsigned int getTime() const
      {
          return  ms_usedTime;
      }
      static unsigned int ms_usedTime;
};
myTimeEatingClass::ms_CreationTime=0; 
myFunc()
{
    for (int counter = 0; counter <= 10; counter++) {

        myTimeEatingClass timeEater();
        //do something
    }
    cout << "Creating class took "<< timeEater.getTime() <<"seconds at all<<endl;

}
myOtherFunc()
{
    myTimeEatingClass timeEater();
    for (int counter = 0; counter <= 10; counter++) {
        //do something
    }
    cout << "Creating class took "<< timeEater.getTime() <<"seconds at all<<endl;

}

Bạn sẽ đợi 55 giây cho đến khi bạn nhận được đầu ra của myFunc. Chỉ vì mỗi vòng lặp và bộ hủy cùng nhau cần 5 giây để hoàn thành.

Bạn sẽ cần 5 giây cho đến khi bạn nhận được đầu ra của myOtherFunc.

Tất nhiên, đây là một ví dụ điên rồ.

Nhưng nó minh họa rằng nó có thể trở thành một vấn đề hiệu năng khi mỗi vòng lặp cùng một cấu trúc được thực hiện khi hàm tạo và / hoặc hàm hủy cần một thời gian.


2
Chà, về mặt kỹ thuật trong phiên bản thứ hai, bạn sẽ nhận được đầu ra chỉ sau 2 giây, vì bạn chưa phá hủy đối tượng .....
Chrys

12

Tôi đã không đăng bài để trả lời các câu hỏi của JeremyRR (vì chúng đã được trả lời); thay vào đó, tôi chỉ đăng để đưa ra một gợi ý.

Đối với JeremyRR, bạn có thể làm điều này:

{
  string someString = "testing";   

  for(int counter = 0; counter <= 10; counter++)
  {
    cout << someString;
  }

  // The variable is in scope.
}

// The variable is no longer in scope.

Tôi không biết nếu bạn nhận ra (tôi đã không biết khi tôi mới bắt đầu lập trình), các dấu ngoặc đó (miễn là chúng ở trong cặp) có thể được đặt ở bất kỳ đâu trong mã, không chỉ sau "if", "for", " trong khi ", v.v.

Mã của tôi được biên dịch trong Microsoft Visual C ++ 2010 Express, vì vậy tôi biết nó hoạt động; Ngoài ra, tôi đã cố gắng sử dụng biến bên ngoài dấu ngoặc mà nó được xác định và tôi đã nhận được một lỗi, vì vậy tôi biết rằng biến đó đã bị "phá hủy".

Tôi không biết có nên sử dụng phương pháp này không, vì rất nhiều dấu ngoặc không được gắn nhãn có thể nhanh chóng làm cho mã không thể đọc được, nhưng có thể một số nhận xét có thể làm sáng tỏ mọi thứ.


4
Đối với tôi, đây là một câu trả lời rất hợp pháp mang lại một gợi ý liên quan trực tiếp đến câu hỏi. Bạn có phiếu bầu của tôi!
Alexis Leclerc

0

Đó là một thực tiễn rất tốt, vì tất cả các câu trả lời ở trên cung cấp khía cạnh lý thuyết rất tốt của câu hỏi, hãy để tôi xem sơ qua về mã, tôi đã cố gắng giải quyết DFS qua GEEKSFORGEEKS, tôi gặp phải vấn đề tối ưu hóa ...... Nếu bạn cố gắng giải quyết mã khai báo số nguyên bên ngoài vòng lặp sẽ cung cấp cho bạn Lỗi tối ưu hóa ..

stack<int> st;
st.push(s);
cout<<s<<" ";
vis[s]=1;
int flag=0;
int top=0;
while(!st.empty()){
    top = st.top();
    for(int i=0;i<g[top].size();i++){
        if(vis[g[top][i]] != 1){
            st.push(g[top][i]);
            cout<<g[top][i]<<" ";
            vis[g[top][i]]=1;
            flag=1;
            break;
        }
    }
    if(!flag){
        st.pop();
    }
}

Bây giờ đặt số nguyên bên trong vòng lặp này sẽ cho bạn câu trả lời đúng ...

stack<int> st;
st.push(s);
cout<<s<<" ";
vis[s]=1;
// int flag=0;
// int top=0;
while(!st.empty()){
    int top = st.top();
    int flag = 0;
    for(int i=0;i<g[top].size();i++){
        if(vis[g[top][i]] != 1){
            st.push(g[top][i]);
            cout<<g[top][i]<<" ";
            vis[g[top][i]]=1;
            flag=1;
            break;
        }
    }
    if(!flag){
        st.pop();
    }
}

điều này hoàn toàn phản ánh những gì ngài @justin đã nói trong bình luận thứ 2 .... hãy thử điều này tại đây https://practice.geekforgeek.org/probols/depth-first-traversal-for-a-graph/1 . chỉ cần cho nó một shot .... bạn sẽ nhận được nó. Hy vọng sự giúp đỡ này.


Tôi không nghĩ rằng điều này áp dụng cho câu hỏi. Rõ ràng, trong trường hợp của bạn ở trên nó có vấn đề. Câu hỏi đã được xử lý trong trường hợp khi định nghĩa biến có thể được định nghĩa ở nơi khác mà không thay đổi hành vi của mã.
pcarter

Trong mã bạn đã đăng, vấn đề không phải là định nghĩa mà là phần khởi tạo. flagnên được khởi tạo lại ở 0 mỗi whilelần lặp. Đó là một vấn đề logic, không phải là một vấn đề định nghĩa.
Martin Véronneau

0

Chương 4.8 Cấu trúc khối trong Ngôn ngữ lập trình C của K & R 2.Ed. :

Một biến tự động được khai báo và khởi tạo trong một khối được khởi tạo mỗi khi khối được nhập.

Tôi có thể đã bỏ lỡ khi xem mô tả có liên quan trong cuốn sách như:

Một biến tự động được khai báo và khởi tạo trong một khối chỉ được cấp phát một lần trước khi khối được nhập.

Nhưng một thử nghiệm đơn giản có thể chứng minh giả định được tổ chức:

 #include <stdio.h>                                                                                                    

 int main(int argc, char *argv[]) {                                                                                    
     for (int i = 0; i < 2; i++) {                                                                                     
         for (int j = 0; j < 2; j++) {                                                                                 
             int k;                                                                                                    
             printf("%p\n", &k);                                                                                       
         }                                                                                                             
     }                                                                                                                 
     return 0;                                                                                                         
 }                                                                                                                     
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.