Chức năng vô tình làm mất hiệu lực tham số tham chiếu - điều gì đã xảy ra?


54

Hôm nay chúng tôi đã tìm ra nguyên nhân của một lỗi khó chịu chỉ xảy ra không liên tục trên một số nền tảng nhất định. Đun sôi xuống, mã của chúng tôi trông như thế này:

class Foo {
  map<string,string> m;

  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }

  void B() {
    while (!m.empty()) {
      auto toDelete = m.begin();
      A(toDelete->first);
    }
  }
}

Vấn đề có vẻ rõ ràng trong trường hợp đơn giản này: Bchuyển tham chiếu đến khóa tới A, loại bỏ mục nhập bản đồ trước khi thử in nó. (Trong trường hợp của chúng tôi, nó không được in, nhưng được sử dụng theo cách phức tạp hơn) Đây tất nhiên là hành vi không xác định, vì keylà một tham chiếu lơ lửng sau khi gọi đến erase.

Sửa lỗi này là chuyện nhỏ - chúng tôi chỉ thay đổi loại tham số từ const string&thành string. Câu hỏi là: làm thế nào chúng ta có thể tránh được lỗi này ngay từ đầu? Có vẻ như cả hai chức năng đã làm đúng:

  • Akhông có cách nào để biết điều đó keyđề cập đến thứ mà nó sắp phá hủy.
  • Bcó thể đã tạo một bản sao trước khi chuyển nó tới A, nhưng đó không phải là công việc của callee để quyết định lấy tham số theo giá trị hay bằng tham chiếu?

Có một số quy tắc chúng tôi không tuân theo?

Câu trả lời:


35

Akhông có cách nào để biết điều đó keyđề cập đến thứ mà nó sắp phá hủy.

Trong khi điều này là đúng, Acó biết những điều sau đây:

  1. Mục đích của nó là phá hủy một cái gì đó .

  2. Nó nhận một tham số thuộc cùng loại chính xác của thứ mà nó sẽ phá hủy.

Với những sự kiện, nó là có thể cho Ađể tiêu diệt tham số riêng của nó nếu nó có các thông số như một con trỏ / tham khảo. Đây không phải là nơi duy nhất trong C ++, nơi cần cân nhắc như vậy.

Tình huống này tương tự như bản chất của operator=toán tử gán có nghĩa là bạn có thể cần quan tâm đến việc tự gán. Đó là một khả năng vì loại thisvà loại tham số tham chiếu là như nhau.

Cần lưu ý rằng điều này chỉ có vấn đề vì Asau này có ý định sử dụng keytham số sau khi loại bỏ mục. Nếu không, thì nó sẽ ổn thôi. Tất nhiên, sau đó mọi thứ trở nên dễ dàng để mọi thứ hoạt động hoàn hảo, sau đó ai đó thay đổi Ađể sử dụng keysau khi nó có khả năng bị phá hủy.

Đó sẽ là một nơi tốt cho một nhận xét.

Có một số quy tắc chúng tôi không tuân theo?

Trong C ++, bạn không thể hoạt động theo giả định rằng nếu bạn mù quáng tuân theo một bộ quy tắc, mã của bạn sẽ an toàn 100%. Chúng ta không thể có quy tắc cho mọi thứ .

Xem xét điểm # 2 ở trên. Acó thể đã lấy một số tham số của một loại khác với khóa, nhưng bản thân đối tượng có thể là một tiểu thể của một khóa trong bản đồ. Trong C ++ 14, findcó thể lấy một loại khác với loại khóa, miễn là có sự so sánh hợp lệ giữa chúng. Vì vậy, nếu bạn làm như vậy m.erase(m.find(key)), bạn có thể hủy tham số mặc dù loại tham số không phải là loại khóa.

Vì vậy, một quy tắc như "nếu loại tham số và loại khóa giống nhau, hãy lấy chúng theo giá trị" sẽ không cứu bạn. Bạn sẽ cần nhiều thông tin hơn thế.

Cuối cùng, bạn cần chú ý đến các trường hợp sử dụng cụ thể và phán đoán, thực hiện theo kinh nghiệm.


10
Chà, bạn có thể có quy tắc "không bao giờ chia sẻ trạng thái có thể thay đổi" hoặc đó là "không bao giờ thay đổi trạng thái chia sẻ", nhưng sau đó bạn sẽ phải vật lộn để viết c ++ có thể nhận dạng
Caleth 8/12/2016

7
@Caleth Nếu bạn muốn sử dụng các quy tắc đó, C ++ có lẽ không phải là ngôn ngữ dành cho bạn.
dùng253751

3
@Caleth Bạn đang mô tả Rust?
Malcolm

1
"Chúng tôi không thể có quy tắc cho tất cả mọi thứ." Vâng, chúng tôi có thể. cstheory.stackexchange.com/q / 4052
Ouroborus

23

Tôi sẽ nói có, có một quy tắc khá đơn giản mà bạn đã phá vỡ sẽ cứu bạn: nguyên tắc trách nhiệm duy nhất.

Ngay bây giờ, Ađược thông qua một tham số mà nó sử dụng để loại bỏ một mục khỏi bản đồ thực hiện một số xử lý khác (in như được hiển thị ở trên, rõ ràng là một cái gì đó khác trong mã thực). Kết hợp những trách nhiệm đó đối với tôi giống như phần lớn nguồn gốc của vấn đề.

Nếu chúng ta có một hàm chỉ xóa giá trị khỏi bản đồ và một hàm khác chỉ xử lý một giá trị từ bản đồ, chúng ta sẽ phải gọi từng hàm từ mã cấp cao hơn, vì vậy chúng ta sẽ kết thúc bằng một thứ như thế này :

std::string &key = get_value_from_map();
destroy(key);
continue_to_use(key);

Cấp, những cái tên tôi đã sử dụng chắc chắn làm cho vấn đề trở nên rõ ràng hơn so với tên thật, nhưng nếu tên đó có ý nghĩa gì cả, thì gần như chắc chắn rằng chúng tôi đang cố gắng tiếp tục sử dụng tài liệu tham khảo sau đó đã bị vô hiệu. Sự thay đổi đơn giản của bối cảnh làm cho vấn đề rõ ràng hơn nhiều.


3
Vâng, đó là một quan sát hợp lệ, nó chỉ áp dụng rất hẹp cho trường hợp này. Có rất nhiều ví dụ về việc SRP được tôn trọng và vẫn còn các vấn đề về chức năng có khả năng làm mất hiệu lực tham số của chính nó.
Ben Voigt

5
@BenVoigt: Chỉ cần vô hiệu hóa tham số của nó không gây ra vấn đề gì. Nó tiếp tục sử dụng tham số sau khi nó bị vô hiệu hóa dẫn đến sự cố. Nhưng cuối cùng là có, bạn đã đúng: trong khi nó sẽ cứu anh ta trong trường hợp này, chắc chắn có những trường hợp không đủ.
Jerry Coffin

3
Khi viết một ví dụ đơn giản, bạn phải bỏ qua một số chi tiết, và đôi khi nó chỉ ra rằng một trong những chi tiết đó rất quan trọng. Trong trường hợp của chúng tôi, Athực sự tìm kiếm keytrong hai bản đồ khác nhau và, nếu tìm thấy, đã xóa các mục cộng với một số dọn dẹp bổ sung. Vì vậy, không rõ ràng rằng ASRP đã vi phạm của chúng tôi . Tôi tự hỏi nếu tôi nên cập nhật câu hỏi tại thời điểm này.
Nikolai

2
Để mở rộng theo quan điểm của @BenVoigt: trong ví dụ của Nicolai, m.erase(key)có trách nhiệm đầu tiên và cout << "Erased: " << keycó trách nhiệm thứ hai, do đó, cấu trúc của mã được hiển thị trong câu trả lời này thực sự không khác với cấu trúc của mã trong ví dụ, nhưng trong thế giới thực vấn đề đã bị bỏ qua. Nguyên tắc trách nhiệm duy nhất không có gì để đảm bảo, hoặc thậm chí làm cho nó có nhiều khả năng hơn, rằng các chuỗi mâu thuẫn của các hành động đơn lẻ sẽ xuất hiện gần nhau trong mã thế giới thực.
sdenham

10

Có một số quy tắc chúng tôi không tuân theo?

Có, bạn không thể ghi lại chức năng .

Nếu không có mô tả về hợp đồng chuyển tham số (cụ thể là phần liên quan đến hiệu lực của tham số - là ở phần đầu của lệnh gọi hoặc trong suốt), không thể biết được lỗi có xảy ra hay không (nếu hợp đồng cuộc gọi là tham số hợp lệ khi cuộc gọi bắt đầu, chức năng phải tạo một bản sao trước khi thực hiện bất kỳ hành động nào có thể làm mất hiệu lực tham số) hoặc trong người gọi (nếu hợp đồng cuộc gọi là tham số phải duy trì hợp lệ trong suốt cuộc gọi, người gọi không thể vượt qua một tham chiếu đến dữ liệu bên trong bộ sưu tập đang được sửa đổi).

Ví dụ, tiêu chuẩn C ++ tự xác định rằng:

Nếu một đối số cho hàm có giá trị không hợp lệ (chẳng hạn như giá trị bên ngoài miền của hàm hoặc con trỏ không hợp lệ cho mục đích sử dụng của nó), hành vi không được xác định.

nhưng nó không xác định liệu điều này chỉ áp dụng cho thời điểm cuộc gọi được thực hiện hay trong suốt quá trình thực thi chức năng. Tuy nhiên, trong nhiều trường hợp, rõ ràng chỉ có điều sau thậm chí là có thể - cụ thể là khi đối số không thể được giữ nguyên bằng cách tạo một bản sao.

Có khá nhiều trường hợp trong thế giới thực mà sự khác biệt này xuất hiện. Ví dụ: nối thêm std::vector<T>vào chính nó


"không xác định liệu điều này chỉ áp dụng cho thời điểm cuộc gọi được thực hiện hay trong suốt quá trình thực thi chức năng." Trong thực tế, trình biên dịch thực hiện khá nhiều thứ họ muốn trong suốt hàm khi UB được gọi. Điều này có thể dẫn đến một số hành vi thực sự kỳ lạ nếu lập trình viên không bắt được UB.

@snowman trong khi thú vị, sắp xếp lại UB hoàn toàn không liên quan đến những gì tôi thảo luận trong câu trả lời này, đó là trách nhiệm đảm bảo tính hợp lệ (để UB không bao giờ xảy ra).
Ben Voigt

đó chính xác là quan điểm của tôi: người viết mã cần có trách nhiệm tránh UB để tránh toàn bộ lỗ thỏ đầy vấn đề.

@Snowman: Không có "một người" nào viết tất cả mã trong một dự án. Đó là một lý do mà tài liệu giao diện rất quan trọng. Một điều nữa là các giao diện được xác định rõ sẽ giảm số lượng mã cần phải suy luận cùng một lúc - đối với bất kỳ dự án không tầm thường nào, không ai có thể "chịu trách nhiệm" khi nghĩ về tính chính xác của mọi tuyên bố.
Ben Voigt

Tôi không bao giờ nói một người viết tất cả các mã. Tại một thời điểm, một lập trình viên có thể đang xem xét một chức năng hoặc viết mã. Tất cả những gì tôi đang cố gắng nói là bất cứ ai đang xem mã cần phải cẩn thận vì trong thực tế, UB bị lây nhiễm và lây lan từ một dòng mã trên phạm vi rộng hơn một khi trình biên dịch được tham gia. Điều này quay trở lại quan điểm của bạn về việc vi phạm hợp đồng của một chức năng: Tôi đồng ý với bạn, nhưng nói rằng nó có thể trở thành một vấn đề thậm chí còn lớn hơn.

2

Có một số quy tắc chúng tôi không tuân theo?

Vâng, bạn đã không kiểm tra nó một cách chính xác. Bạn không đơn độc và bạn đang ở đúng nơi để học :)


C ++ có rất nhiều Hành vi không xác định, Hành vi không xác định biểu hiện theo những cách tinh tế và khó chịu.

Có thể bạn không bao giờ có thể viết mã C ++ an toàn 100%, nhưng bạn chắc chắn có thể giảm xác suất vô tình giới thiệu Hành vi không xác định trong cơ sở mã của mình bằng cách sử dụng một số công cụ.

  1. Trình biên dịch cảnh báo
  2. Phân tích tĩnh (phiên bản mở rộng của các cảnh báo)
  3. Kiểm tra cụ
  4. Binaries sản xuất cứng

Trong trường hợp của bạn, tôi nghi ngờ (1) và (2) sẽ giúp ích nhiều, mặc dù nói chung tôi khuyên bạn nên sử dụng chúng. Bây giờ hãy tập trung vào hai người kia.

Cả gcc và Clang đều có -fsanitizecờ gắn các chương trình bạn biên dịch để kiểm tra nhiều vấn đề khác nhau. -fsanitize=undefinedví dụ sẽ bắt được dòng chảy tràn / số nguyên đã ký, dịch chuyển với số lượng quá cao, v.v ... Trong trường hợp cụ thể của bạn, -fsanitize=address-fsanitize=memorycó khả năng sẽ xử lý vấn đề này ... với điều kiện bạn phải kiểm tra gọi hàm. Để hoàn thiện, -fsanitize=threadđáng để sử dụng nếu bạn có một cơ sở mã đa luồng. Nếu bạn không thể triển khai nhị phân (ví dụ: bạn có thư viện của bên thứ 3 mà không có nguồn của họ), thì bạn cũng có thể sử dụng valgrindmặc dù nói chung là chậm hơn.

Trình biên dịch gần đây cũng có khả năng làm cứng sự giàu có . Sự khác biệt chính với các nhị phân cụ, là các kiểm tra cứng được thiết kế để có tác động thấp đến hiệu suất (<1%), làm cho chúng phù hợp với mã sản xuất nói chung. Nổi tiếng nhất là kiểm tra CFI (Kiểm soát toàn vẹn dòng điều khiển) được thiết kế để ngăn chặn các cuộc tấn công phá vỡ ngăn xếp và tấn công con trỏ ảo trong số các cách khác để phá vỡ luồng điều khiển.

Điểm của cả (3) và (4) là biến một thất bại không liên tục thành một thất bại nhất định : cả hai đều tuân theo nguyên tắc thất bại nhanh . Điều này có nghĩa rằng:

  • nó luôn thất bại khi bạn bước lên mìn
  • nó không thành công ngay lập tức , chỉ ra lỗi của bạn thay vì làm hỏng bộ nhớ ngẫu nhiên, v.v ...

Kết hợp (3) với phạm vi kiểm tra tốt sẽ nắm bắt được hầu hết các vấn đề trước khi chúng được sản xuất. Sử dụng (4) trong sản xuất có thể là sự khác biệt giữa một lỗi khó chịu và khai thác.


0

@note: bài đăng này chỉ cần thêm nhiều đối số trên đầu câu trả lời của Ben Voigt .

Câu hỏi là: làm thế nào chúng ta có thể tránh được lỗi này ngay từ đầu? Có vẻ như cả hai chức năng đã làm đúng:

  • A không có cách nào để biết rằng chìa khóa đề cập đến thứ mà nó sắp phá hủy.
  • B có thể đã tạo một bản sao trước khi chuyển nó cho A, nhưng đó không phải là công việc của callee để quyết định lấy tham số theo giá trị hay bằng tham chiếu?

Cả hai chức năng đã làm điều đúng.

Vấn đề nằm ở mã máy khách, không tính đến các tác dụng phụ của việc gọi A.

C ++ không có cách trực tiếp chỉ định tác dụng phụ trong ngôn ngữ.

Điều này có nghĩa là tùy thuộc vào bạn (và nhóm của bạn) để đảm bảo những thứ như tác dụng phụ có thể nhìn thấy trong mã (dưới dạng tài liệu) và được duy trì với mã (có lẽ bạn nên xem xét ghi lại các điều kiện trước, điều kiện hậu và bất biến là tốt, vì lý do tầm nhìn là tốt).

Thay đổi mã:

class Foo {
  map<string,string> m;

  /// \sideeffect invalidates iterators
  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }
  ...

Từ thời điểm này, bạn có một cái gì đó trên đầu API cho bạn biết rằng bạn nên có một bài kiểm tra đơn vị cho nó; Nó cũng cho bạn biết cách sử dụng (và không sử dụng) API.


-4

Làm thế nào chúng ta có thể tránh được lỗi này ở nơi đầu tiên?

Chỉ có một cách để tránh lỗi: ngừng viết mã. Mọi thứ khác đều thất bại theo một cách nào đó.

Tuy nhiên, mã kiểm tra ở các cấp độ khác nhau (kiểm tra đơn vị, kiểm tra chức năng, kiểm tra tích hợp, kiểm tra chấp nhận, v.v.) sẽ không chỉ cải thiện chất lượng mã mà còn giảm số lượng lỗi.


1
Điều này là hoàn toàn vô nghĩa. Có không chỉ có một cách để tránh lỗi. Mặc dù đúng là cách duy nhất để tránh hoàn toàn sự tồn tại của lỗi là không bao giờ viết mã, nhưng cũng đúng (và hữu ích hơn nhiều) rằng có nhiều quy trình kỹ thuật phần mềm khác nhau mà bạn có thể làm theo, cả khi ban đầu viết mã và khi kiểm tra nó, điều đó có thể làm giảm đáng kể sự hiện diện của lỗi. Mọi người đều biết về giai đoạn thử nghiệm, nhưng tác động lớn nhất thường có thể có với chi phí thấp nhất bằng cách tuân theo các thực tiễn và thành ngữ thiết kế có trách nhiệm trong khi viết mã ở vị trí đầu tiên.
Cody Grey
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.