ฟังก์ชั่นโดยไม่ได้ตั้งใจทำให้พารามิเตอร์อ้างอิง - สิ่งที่ผิดพลาด?


54

วันนี้เราพบสาเหตุของข้อผิดพลาดที่น่ารังเกียจที่เกิดขึ้นเป็นระยะ ๆ ในบางแพลตฟอร์มเท่านั้น ต้มโค้ดของเราออกมาเป็นแบบนี้:

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);
    }
  }
}

ปัญหาอาจดูเหมือนชัดเจนในกรณีที่ง่ายนี้: Bผ่านการอ้างอิงถึงคีย์Aซึ่งจะลบรายการแผนที่ก่อนที่จะพยายามพิมพ์ (ในกรณีของเรามันก็ไม่ได้พิมพ์ แต่ใช้ในทางที่ซับซ้อนมากขึ้น) นี้เป็นหลักสูตรที่ไม่ได้กำหนดพฤติกรรมเนื่องจากมีการอ้างอิงห้อยหลังจากการเรียกร้องให้keyerase

แก้ไขครั้งนี้เป็นที่น่ารำคาญ - เราเพิ่งเปลี่ยนประเภทพารามิเตอร์จากไปconst string& stringคำถามคือเราจะหลีกเลี่ยงข้อผิดพลาดนี้ได้ตั้งแต่แรกอย่างไร? ดูเหมือนว่าทั้งสองฟังก์ชั่นทำในสิ่งที่ถูกต้อง:

  • Aไม่มีทางรู้ว่าkeyหมายถึงสิ่งที่มันกำลังจะทำลาย
  • Bสามารถทำสำเนาก่อนส่งให้Aแต่เป็นหน้าที่ของผู้ตัดสินใจว่าจะรับพารามิเตอร์ตามมูลค่าหรือโดยอ้างอิงหรือไม่

มีกฎบางอย่างที่เราไม่สามารถทำตามได้หรือไม่?

คำตอบ:


35

Aไม่มีทางรู้ว่าkeyหมายถึงสิ่งที่มันกำลังจะทำลาย

ในขณะนี้เป็นจริงAรู้สิ่งต่าง ๆ ต่อไปนี้:

  1. วัตถุประสงค์คือเพื่อทำลายบางสิ่งบางอย่าง

  2. ใช้พารามิเตอร์ซึ่งเป็นชนิดเดียวกันกับสิ่งที่มันจะทำลาย

ได้รับข้อเท็จจริงเหล่านี้ก็เป็นสิ่งที่เป็นไปได้สำหรับการAที่จะทำลายพารามิเตอร์ของตัวเองถ้ามันใช้เวลาพารามิเตอร์เป็นตัวชี้ / อ้างอิง นี่ไม่ใช่สถานที่เดียวใน C ++ ที่ต้องคำนึงถึงข้อควรพิจารณาดังกล่าว

สถานการณ์นี้คล้ายกับลักษณะของoperator=ผู้ประกอบการที่ได้รับมอบหมายหมายความว่าคุณอาจต้องกังวลเกี่ยวกับการมอบหมายด้วยตนเอง นั่นเป็นความเป็นไปได้เพราะประเภทthisและประเภทของพารามิเตอร์อ้างอิงเหมือนกัน

ควรสังเกตว่านี่เป็นปัญหาเท่านั้นเพราะAภายหลังตั้งใจจะใช้keyพารามิเตอร์หลังจากลบรายการ หากไม่เป็นเช่นนั้นก็จะไม่เป็นไร แน่นอนว่ามันกลายเป็นเรื่องง่ายที่จะมีทุกสิ่งที่ทำงานอย่างสมบูรณ์แบบจากนั้นบางคนก็เปลี่ยนAไปใช้keyหลังจากที่มันถูกทำลาย

นั่นจะเป็นสถานที่ที่ดีสำหรับความคิดเห็น

มีกฎบางอย่างที่เราไม่สามารถทำตามได้หรือไม่?

ใน C ++ คุณไม่สามารถทำงานภายใต้สมมติฐานที่ว่าถ้าคุณทำตามกฎชุดรหัสของคุณจะปลอดภัย 100% เราไม่สามารถมีกฎสำหรับทุกอย่าง

พิจารณาประเด็นที่ 2 ด้านบน Aอาจมีพารามิเตอร์บางประเภทที่แตกต่างจากคีย์ แต่วัตถุนั้นอาจเป็น subobject ของคีย์ในแผนที่ ใน C ++ 14 findสามารถใช้ประเภทที่แตกต่างจากประเภทคีย์ตราบใดที่มีการเปรียบเทียบที่ถูกต้องระหว่างพวกเขา ดังนั้นหากคุณทำเช่นm.erase(m.find(key))นั้นคุณสามารถทำลายพารามิเตอร์ได้แม้ว่าประเภทของพารามิเตอร์จะไม่ใช่ประเภทหลัก

ดังนั้นกฎเช่น "ถ้าประเภทพารามิเตอร์และประเภทคีย์เหมือนกันให้ใช้ค่า" จะไม่ช่วยคุณ คุณต้องการข้อมูลมากกว่านี้

ท้ายที่สุดคุณต้องใส่ใจกับกรณีการใช้งานเฉพาะของคุณและการตัดสินใจใช้สิทธิโดยได้รับการบอกเล่าจากประสบการณ์


10
ดีคุณอาจมีกฎ "ไม่เคยแบ่งปันรัฐที่ไม่แน่นอน" หรือเป็นสอง "ไม่เคยกลายพันธุ์รัฐที่ใช้ร่วมกัน" แต่แล้วคุณจะพยายามที่จะเขียน c ++ ที่ระบุตัวตน
Caleth

7
@Caleth หากคุณต้องการใช้กฎเหล่านั้น C ++ อาจไม่ใช่ภาษาสำหรับคุณ
user253751

3
@Caleth คุณอธิบายสนิมหรือไม่
Malcolm

1
"เราไม่มีกฎสำหรับทุกสิ่ง" ใช่เราทำได้ cstheory.stackexchange.com/q/4052
Ouroborus

23

ฉันจะบอกว่าใช่มีกฎง่ายๆที่คุณทำลายซึ่งจะช่วยให้คุณ: หลักการความรับผิดชอบเดียว

ตอนนี้Aผ่านพารามิเตอร์ที่ใช้ในการลบทั้งสองรายการออกจากแผนที่และดำเนินการอื่น ๆ (พิมพ์ตามที่แสดงด้านบนซึ่งเห็นได้ชัดว่าเป็นอย่างอื่นในรหัสจริง) การรวมความรับผิดชอบเหล่านั้นเข้ากับฉันเหมือนแหล่งที่มาของปัญหา

ถ้าเรามีฟังก์ชันหนึ่งที่เพียงแค่ลบค่าจากแผนที่, และอื่น ๆ ที่เพียงไม่ประมวลผลของค่าจากแผนที่เราจะต้องโทรหากันจากโค้ดระดับที่สูงขึ้นดังนั้นเราจะจบลงด้วยอะไรเช่นนี้ :

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

ได้รับชื่อที่ฉันใช้อย่างไม่ต้องสงสัยทำให้ปัญหาชัดเจนกว่าชื่อจริง แต่ถ้าชื่อมีความหมายเลยพวกเขาเกือบจะแน่ใจว่าจะทำให้ชัดเจนว่าเรากำลังพยายามใช้การอ้างอิงต่อไปหลังจาก ถูกทำให้ใช้งานไม่ได้ การเปลี่ยนแปลงบริบทอย่างง่ายทำให้ปัญหาชัดเจนขึ้น


3
นั่นเป็นการสังเกตที่ถูกต้องมันใช้กับกรณีนี้แคบมากเท่านั้น มีตัวอย่างมากมายที่เคารพ SRP และยังมีปัญหาของฟังก์ชั่นที่อาจทำให้พารามิเตอร์ของตัวเองใช้ไม่ได้
Ben Voigt

5
@BenVoigt: การทำให้พารามิเตอร์ใช้ไม่ได้จะไม่ทำให้เกิดปัญหา มันยังคงใช้พารามิเตอร์ต่อไปหลังจากถูกทำให้ใช้งานไม่ได้ซึ่งทำให้เกิดปัญหา แต่ท้ายที่สุดแล้วใช่แล้วคุณพูดถูก: แม้ว่ามันจะช่วยเขาได้ในกรณีนี้ แต่ก็มีบางกรณีที่ไม่เพียงพอ
Jerry Coffin

3
เมื่อเขียนตัวอย่างง่าย ๆ คุณต้องละเว้นรายละเอียดบางอย่างและบางครั้งปรากฎว่ารายละเอียดอย่างใดอย่างหนึ่งนั้นสำคัญ ในกรณีของเราค้นหาAจริงkeyในสองแผนที่ที่แตกต่างกันและหากพบให้ลบรายการรวมทั้งล้างข้อมูลเพิ่มเติม ดังนั้นจึงไม่ชัดเจนว่าASRP ที่ละเมิดของเรา ฉันสงสัยว่าฉันควรอัพเดทคำถาม ณ จุดนี้หรือไม่
นิโคไล

2
หากต้องการขยายจุดของ @BenVoigt: ในตัวอย่างของ Nicolai m.erase(key)มีความรับผิดชอบแรกและcout << "Erased: " << keyมีความรับผิดชอบที่สองดังนั้นโครงสร้างของรหัสที่แสดงในคำตอบนี้จะไม่แตกต่างจากโครงสร้างของรหัสในตัวอย่าง แต่ใน โลกแห่งความจริงปัญหาถูกมองข้าม หลักการความรับผิดชอบเพียงอย่างเดียวไม่ได้ทำอะไรที่จะทำให้มั่นใจหรือแม้แต่ทำให้มันมีความเป็นไปได้มากขึ้นว่าลำดับที่ขัดแย้งกันของการกระทำเดี่ยวจะปรากฏขึ้นใกล้ ๆ กันในรหัสโลกแห่งความจริง
sdenham

10

มีกฎบางอย่างที่เราไม่สามารถทำตามได้หรือไม่?

ใช่คุณล้มเหลวในการจัดทำเอกสารการทำงาน

หากไม่มีคำอธิบายของสัญญาการส่งผ่านพารามิเตอร์ (โดยเฉพาะส่วนที่เกี่ยวข้องกับความถูกต้องของพารามิเตอร์ - มันเป็นจุดเริ่มต้นของการเรียกใช้ฟังก์ชันหรือตลอด) มันเป็นไปไม่ได้ที่จะบอกได้ว่าข้อผิดพลาดอยู่ในการใช้งานหรือไม่ คือพารามิเตอร์นั้นถูกต้องเมื่อการโทรเริ่มต้นขึ้นฟังก์ชั่นจะต้องทำสำเนาก่อนที่จะดำเนินการใด ๆ ที่อาจทำให้พารามิเตอร์ใช้ไม่ได้) หรือในผู้โทร (ถ้าสัญญาการโทรคือพารามิเตอร์นั้นจะต้องยังคงใช้ได้ตลอดการโทร ส่งการอ้างอิงไปยังข้อมูลภายในคอลเล็กชันที่กำลังแก้ไข)

ตัวอย่างเช่นมาตรฐาน C ++ ระบุว่า:

หากอาร์กิวเมนต์ของฟังก์ชันมีค่าที่ไม่ถูกต้อง (เช่นค่าภายนอกโดเมนของฟังก์ชันหรือตัวชี้ไม่ถูกต้องสำหรับการใช้งานตามวัตถุประสงค์) พฤติกรรมนั้นจะไม่ได้กำหนดไว้

แต่มันล้มเหลวในการระบุว่าสิ่งนี้ใช้ได้เฉพาะกับการโทรทันทีหรือตลอดการดำเนินการของฟังก์ชั่น อย่างไรก็ตามในหลายกรณีเป็นที่ชัดเจนว่ามีเพียงหลังเท่านั้นที่เป็นไปได้ - กล่าวคือเมื่อการโต้แย้งไม่สามารถใช้งานได้โดยการทำสำเนา

มีบางกรณีในโลกแห่งความจริงที่ความแตกต่างนี้เข้ามาเล่น ตัวอย่างเช่นการต่อท้ายstd::vector<T>เพื่อตัวเอง


"ไม่สามารถระบุได้ว่าสิ่งนี้ใช้ได้เฉพาะกับการโทรทันทีหรือตลอดการดำเนินการของฟังก์ชัน" ในทางปฏิบัติคอมไพเลอร์จะทำทุกอย่างที่ต้องการตลอดทั้งฟังก์ชั่นเมื่อมีการเรียกใช้ UB สิ่งนี้สามารถนำไปสู่พฤติกรรมที่แปลกจริง ๆ ได้หากโปรแกรมเมอร์ไม่ได้จับ UB

@snowman ในขณะที่น่าสนใจการเรียงลำดับใหม่ของ UB นั้นไม่เกี่ยวข้องกับสิ่งที่ฉันพูดถึงในคำตอบนี้ซึ่งเป็นความรับผิดชอบในการรับรองความถูกต้อง (เพื่อไม่ให้ UB เกิดขึ้น)
Ben Voigt

ซึ่งเป็นจุดของฉัน: ผู้เขียนรหัสจะต้องรับผิดชอบในการหลีกเลี่ยง UB เพื่อหลีกเลี่ยงหลุมกระต่ายเต็มไปด้วยปัญหา

@Snowman: ไม่มี "คน ๆ หนึ่ง" ที่เขียนโค้ดทั้งหมดในโครงการ นั่นเป็นเหตุผลหนึ่งที่เอกสารประกอบอินเตอร์เฟสมีความสำคัญ อีกอย่างคืออินเทอร์เฟซที่กำหนดอย่างดีจะลดจำนวนของรหัสที่จำเป็นต้องให้เหตุผลในครั้งเดียว - สำหรับโครงการที่ไม่สำคัญมันเป็นไปไม่ได้ที่ใครบางคนจะ "รับผิดชอบ" สำหรับการคิดเกี่ยวกับความถูกต้องของทุกคำสั่ง
Ben Voigt

ฉันไม่เคยพูดว่ามีคนเขียนรหัสทั้งหมด ในช่วงเวลาหนึ่งโปรแกรมเมอร์อาจมองไปที่ฟังก์ชั่นหรือการเขียนโค้ด ทั้งหมดที่ฉันพยายามจะพูดก็คือว่าใครก็ตามที่กำลังมองหารหัสนั้นต้องระวังเพราะในทางปฏิบัติ UB นั้นติดเชื้อและแพร่กระจายจากโค้ดหนึ่งบรรทัดข้ามขอบเขตที่กว้างขึ้นเมื่อคอมไพเลอร์เกี่ยวข้อง สิ่งนี้กลับไปที่จุดของคุณเกี่ยวกับการละเมิดสัญญาของฟังก์ชั่น: ฉันเห็นด้วยกับคุณ แต่ระบุว่ามันจะกลายเป็นปัญหาที่ใหญ่ขึ้น

2

มีกฎบางอย่างที่เราไม่สามารถทำตามได้หรือไม่?

ใช่คุณไม่สามารถทดสอบได้อย่างถูกต้อง คุณไม่ได้อยู่คนเดียวและคุณมาถูกที่แล้วที่จะเรียนรู้ :)


C ++ มีพฤติกรรมที่ไม่ได้กำหนดจำนวนมากพฤติกรรมที่ไม่ได้กำหนดนั้นมีรูปแบบที่ละเอียดและน่ารำคาญ

คุณอาจไม่สามารถเขียนรหัส C ++ ที่ปลอดภัย 100% ได้ แต่คุณสามารถลดความน่าจะเป็นในการแนะนำพฤติกรรมที่ไม่ได้กำหนดในฐานรหัสของคุณโดยบังเอิญโดยใช้เครื่องมือจำนวนหนึ่ง

  1. คำเตือนของคอมไพเลอร์
  2. การวิเคราะห์แบบคงที่ (คำเตือนรุ่นเพิ่มเติม)
  3. เครื่องมือทดสอบแบบไบนารี
  4. ไบนารีการผลิตที่แข็ง

ในกรณีของคุณฉันสงสัย (1) และ (2) จะช่วยได้มาก แต่โดยทั่วไปฉันแนะนำให้ใช้พวกเขา สำหรับตอนนี้ขอมุ่งเน้นที่อีกสอง

ทั้ง gcc และ Clang มีการ-fsanitizeตั้งค่าสถานะซึ่งเป็นเครื่องมือที่โปรแกรมที่คุณรวบรวมเพื่อตรวจสอบปัญหาต่าง ๆ -fsanitize=undefinedตัวอย่างเช่นจะจับจำนวนเต็มอันเดอร์โฟลว์ / โอเวอร์โฟลว์ที่ลงนามแล้วเลื่อนตามปริมาณที่สูงเกินไป ฯลฯ ... ในกรณีเฉพาะของคุณ-fsanitize=addressและ-fsanitize=memoryมีแนวโน้มที่จะรับปัญหา ... หากคุณมีการทดสอบการโทร เพื่อความสมบูรณ์-fsanitize=threadมีค่าใช้ถ้าคุณมี codebase แบบมัลติเธรด หากคุณไม่สามารถใช้ไบนารี (ตัวอย่างเช่นคุณมีห้องสมุดบุคคลที่สามที่ไม่มีแหล่งที่มา) คุณสามารถใช้valgrindแม้ว่ามันจะช้าลงโดยทั่วไป

คอมไพเลอร์ล่าสุดมีความมั่งคั่งเป็นไปได้แข็ง ข้อแตกต่างที่สำคัญกับไบนารีที่มีอุปกรณ์คือการตรวจสอบการชุบแข็งนั้นได้รับการออกแบบให้มีผลกระทบต่อประสิทธิภาพต่ำ (<1%) ทำให้เหมาะสำหรับรหัสการผลิตโดยทั่วไป รู้จักกันดีที่สุดคือการตรวจสอบ CFI (Control Flow Integrity) ซึ่งออกแบบมาเพื่อทำลายการโจมตีแบบสแต็กยอดเยี่ยม

จุดของทั้งสอง (3) และ (4) คือการเปลี่ยนความล้มเหลวต่อเนื่องเป็นความล้มเหลวของบางอย่างที่พวกเขาทั้งสองตามล้มเหลวอย่างรวดเร็วหลักการ ซึ่งหมายความว่า:

  • มันจะล้มเหลวเสมอเมื่อคุณเหยียบกับระเบิด
  • มันล้มเหลวทันทีโดยชี้ให้คุณเห็นข้อผิดพลาดแทนที่จะสุ่มหน่วยความจำเสียหาย ฯลฯ

การรวม (3) กับการทดสอบที่ดีควรครอบคลุมปัญหาส่วนใหญ่ก่อนที่จะเกิดการผลิต การใช้ (4) ในการผลิตสามารถแตกต่างระหว่างข้อผิดพลาดที่น่ารำคาญและการใช้ประโยชน์


0

@note: โพสต์นี้เพียงแค่เพิ่มการขัดแย้งมากขึ้นด้านบนของคำตอบของเบนยต์

คำถามคือเราจะหลีกเลี่ยงข้อผิดพลาดนี้ได้ตั้งแต่แรกอย่างไร? ดูเหมือนว่าทั้งสองฟังก์ชั่นทำในสิ่งที่ถูกต้อง:

  • A ไม่มีทางรู้ว่ากุญแจนั้นหมายถึงสิ่งที่มันกำลังจะทำลาย
  • B อาจทำสำเนาก่อนที่จะส่งไปยัง A แต่เป็นหน้าที่ของผู้ตัดสินใจว่าจะรับพารามิเตอร์ตามมูลค่าหรือโดยอ้างอิงหรือไม่

ฟังก์ชั่นทั้งสองทำสิ่งที่ถูกต้อง

ปัญหาอยู่ในรหัสลูกค้าซึ่งไม่ได้คำนึงถึงผลข้างเคียงของการโทร A.

C ++ ไม่มีวิธีระบุผลข้างเคียงโดยตรงในภาษา

ซึ่งหมายความว่ามันขึ้นอยู่กับคุณ (และทีมของคุณ) เพื่อให้แน่ใจว่าสิ่งต่าง ๆ เช่นผลข้างเคียงปรากฏอยู่ในรหัส (เป็นเอกสาร) และดูแลรักษาด้วยรหัส (คุณอาจพิจารณาเอกสารก่อนเงื่อนไขเงื่อนไขหลังการเปลี่ยนแปลงและค่าคงที่ ด้วยเหตุผลด้านการมองเห็นเช่นกัน)

การเปลี่ยนรหัส:

class Foo {
  map<string,string> m;

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

จากจุดนี้คุณมีบางอย่างที่เหนือกว่าของ API ที่บอกคุณว่าคุณควรมีการทดสอบหน่วยสำหรับมัน นอกจากนี้ยังบอกวิธีการใช้ (และไม่ใช้) API


-4

เราจะหลีกเลี่ยงข้อผิดพลาดนี้ได้ตั้งแต่แรกอย่างไร?

มีวิธีเดียวเท่านั้นที่จะหลีกเลี่ยงข้อบกพร่องคือหยุดเขียนโค้ด ทุกอย่างอื่นล้มเหลวในทางใดทางหนึ่ง

อย่างไรก็ตามการทดสอบรหัสในระดับต่างๆ (การทดสอบหน่วยการทดสอบการใช้งานการทดสอบการรวมการทดสอบการยอมรับ ฯลฯ ) จะไม่เพียง แต่ปรับปรุงคุณภาพของรหัส แต่ยังลดจำนวนข้อบกพร่อง


1
นี่เป็นเรื่องไร้สาระที่สมบูรณ์ นอกจากนี้ไม่เพียงวิธีหนึ่งที่จะหลีกเลี่ยงข้อผิดพลาด ในขณะที่มันเป็นความจริงเล็กน้อยที่วิธีเดียวที่จะหลีกเลี่ยงการมีอยู่ของบั๊กได้อย่างสมบูรณ์คือการไม่เขียนโค้ด แต่ก็เป็นความจริง (และมีประโยชน์มากกว่า) ที่มีขั้นตอนทางวิศวกรรมซอฟต์แวร์ต่าง ๆ ที่คุณสามารถติดตามได้ เมื่อการทดสอบที่สามารถอย่างมีนัยสำคัญลดการปรากฏตัวของข้อบกพร่อง ทุกคนรู้เกี่ยวกับขั้นตอนการทดสอบ แต่ผลกระทบที่ใหญ่ที่สุดมักจะมีค่าใช้จ่ายต่ำที่สุดโดยทำตามแนวทางการออกแบบและสำนวนที่มีความรับผิดชอบขณะที่เขียนรหัสในตอนแรก
โคดี้เกรย์
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.