การกำจัดคนเก็บขยะ: The RAII Way

เผยแพร่แล้ว: 2022-03-11

ในตอนแรกมี C ใน C มีการจัดสรรหน่วยความจำสามประเภท: สแตติกอัตโนมัติและไดนามิก ตัวแปรคงที่คือค่าคงที่ที่ฝังอยู่ในไฟล์ต้นทาง และเนื่องจากพวกมันรู้จักขนาดและไม่เคยเปลี่ยนแปลง พวกมันจึงไม่ค่อยน่าสนใจเท่าไหร่ การจัดสรรอัตโนมัติถือได้ว่าเป็นการจัดสรรสแต็ก - พื้นที่จะถูกจัดสรรเมื่อมีการป้อนบล็อกคำศัพท์ และทำให้ว่างเมื่อออกจากบล็อกนั้น คุณสมบัติที่สำคัญที่สุดของมันเกี่ยวข้องโดยตรงกับสิ่งนั้น จนถึง C99 ตัวแปรที่จัดสรรโดยอัตโนมัติจะต้องทราบขนาดของมันในขณะคอมไพล์ ซึ่งหมายความว่าสตริง รายการ แผนที่ และโครงสร้างใดๆ ที่มาจากสิ่งเหล่านี้ต้องอยู่บนฮีปในหน่วยความจำแบบไดนามิก

การกำจัดคนเก็บขยะ: The RAII Way

หน่วยความจำไดนามิกได้รับการจัดสรรและปลดปล่อยอย่างชัดเจนโดยโปรแกรมเมอร์โดยใช้การดำเนินการพื้นฐานสี่ประการ: malloc, realloc, calloc และฟรี สองตัวแรกนี้ไม่มีการเริ่มต้นใด ๆ หน่วยความจำอาจมี cruft ทั้งหมดยกเว้นฟรีสามารถล้มเหลว ในกรณีนั้น จะส่งกลับพอยน์เตอร์ null ซึ่งการเข้าถึงนั้นเป็นพฤติกรรมที่ไม่ได้กำหนดไว้ ในกรณีที่ดีที่สุด โปรแกรมของคุณจะระเบิด ในกรณีที่เลวร้ายที่สุด โปรแกรมของคุณดูเหมือนจะทำงานได้ชั่วขณะหนึ่ง กำลังประมวลผลข้อมูลขยะก่อนที่จะระเบิด

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

 int main() { char *str = (char *) malloc(7); strcpy(str, "toptal"); printf("char array = \"%s\" @ %u\n", str, str); str = (char *) realloc(str, 11); strcat(str, ".com"); printf("char array = \"%s\" @ %u\n", str, str); free(str); return(0); }
 $ make runc gcc -oc cc ./c char * (null terminated): toptal @ 66576 char * (null terminated): toptal.com @ 66576

โค้ดนั้นเรียบง่ายอย่างที่มันเป็นอยู่แล้ว มีหนึ่ง antipattern และหนึ่งการตัดสินใจที่น่าสงสัย ในชีวิตจริง คุณไม่ควรเขียนจำนวนไบต์เป็นตัวอักษร แต่ใช้ฟังก์ชัน sizeof แทน ในทำนองเดียวกัน เราจัดสรรอาร์เรย์ char * ให้ตรงกับขนาดของสตริงที่เราต้องการสองครั้ง (มากกว่าความยาวของสตริงหนึ่งครั้ง เพื่อพิจารณาการยกเลิกค่า null) ซึ่งเป็นการดำเนินการที่ค่อนข้างแพง โปรแกรมที่ซับซ้อนมากขึ้นอาจสร้างบัฟเฟอร์สตริงที่ใหญ่ขึ้น ทำให้ขนาดของสตริงเติบโตขึ้น

การประดิษฐ์ของ RAII: ความหวังใหม่

การจัดการด้วยตนเองทั้งหมดนั้นไม่เป็นที่พอใจ ในช่วงกลางยุค 80 Bjarne Stroustrup ได้คิดค้นกระบวนทัศน์ใหม่สำหรับภาษาใหม่ของเขา C++ เขาเรียกมันว่า Resource Acquisition Is Initialization และข้อมูลเชิงลึกพื้นฐานมีดังต่อไปนี้: วัตถุสามารถกำหนดให้มีตัวสร้างและตัวทำลายซึ่งถูกเรียกโดยอัตโนมัติในเวลาที่เหมาะสมโดยคอมไพเลอร์ ซึ่งเป็นวิธีที่สะดวกกว่ามากในการจัดการหน่วยความจำของวัตถุที่กำหนด จำเป็นและเทคนิคนี้ยังมีประโยชน์สำหรับทรัพยากรที่ไม่ใช่หน่วยความจำ

ซึ่งหมายความว่าตัวอย่างข้างต้นใน C ++ นั้นสะอาดกว่ามาก:

 int main() { std::string str = std::string ("toptal"); std::cout << "string object: " << str << " @ " << &str << "\n"; str += ".com"; std::cout << "string object: " << str << " @ " << &str << "\n"; return(0); }
 $ g++ -o ex_1 ex_1.cpp && ./ex_1 string object: toptal @ 0x5fcaf0 string object: toptal.com @ 0x5fcaf0

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

 vector<string> read_lines_from_file(string &file_name) { vector<string> lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines.push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); int count = read_lines_from_file(file_name).size(); cout << "File " << file_name << " contains " << count << " lines."; return 0; }
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.

ทั้งหมดนี้ดูเหมือนค่อนข้างตรงไปตรงมา เส้นเวกเตอร์ถูกเติม ส่งคืน และเรียก อย่างไรก็ตาม เนื่องจากเป็นโปรแกรมเมอร์ที่ทรงประสิทธิภาพซึ่งใส่ใจเกี่ยวกับประสิทธิภาพ บางสิ่งเกี่ยวกับเรื่องนี้ก็ทำให้เรารู้สึกไม่สบายใจ: ในคำสั่ง return vector ถูกคัดลอกไปยังเวกเตอร์ใหม่เนื่องจากความหมายของค่าในการเล่น ไม่นานก่อนการทำลาย

สิ่งนี้ไม่เป็นความจริงใน C ++ สมัยใหม่อีกต่อไป C ++ 11 นำเสนอแนวคิดของความหมายการเคลื่อนไหวซึ่งต้นกำเนิดถูกทิ้งไว้ในสถานะที่ถูกต้อง (เพื่อให้สามารถทำลายได้อย่างถูกต้อง) แต่ไม่ระบุสถานะ การเรียกกลับเป็นกรณีที่ง่ายมากสำหรับคอมไพเลอร์ในการปรับให้เหมาะสมเพื่อย้ายความหมาย เนื่องจากรู้ว่าที่มาจะถูกทำลายก่อนการเข้าถึงเพิ่มเติมอีกไม่นาน อย่างไรก็ตาม จุดประสงค์ของตัวอย่างนี้คือเพื่อแสดงให้เห็นว่าเหตุใดผู้คนจึงคิดค้นภาษาที่เก็บขยะจำนวนมากในช่วงปลายยุค 80 และต้นยุค 90 และในสมัยนั้น ความหมายการย้าย C++ ก็ไม่สามารถใช้ได้

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

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

 vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; }
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp Segmentation fault (core dumped)

อุ๊ย! ตอนนี้เส้นเป็นตัวชี้แล้ว เราจะเห็นว่าตัวแปรอัตโนมัติทำงานตามที่โฆษณาไว้: เวกเตอร์จะถูกทำลายเมื่อขอบเขตออกไป โดยปล่อยให้ตัวชี้ชี้ไปยังตำแหน่งข้างหน้าในสแต็ก ข้อผิดพลาดในการแบ่งส่วนเป็นเพียงการพยายามเข้าถึงหน่วยความจำที่ผิดกฎหมาย ดังนั้นเราจึงควรคาดหวังสิ่งนั้นจริงๆ ถึงกระนั้น เราต้องการดึงบรรทัดของไฟล์กลับมาจากฟังก์ชันของเรา และที่เป็นธรรมชาติก็คือเพียงแค่ย้ายตัวแปรของเราออกจากสแต็กและเข้าไปในฮีป เสร็จสิ้นด้วยคีย์เวิร์ดใหม่ เราสามารถแก้ไขไฟล์ของเราได้เพียงบรรทัดเดียว โดยเรากำหนดบรรทัด:

 vector<string> * lines = new vector<string>;
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.

น่าเสียดาย ที่ดูเหมือนว่าจะทำงานได้อย่างสมบูรณ์ แต่ก็ยังมีข้อบกพร่อง: หน่วยความจำรั่ว ใน C++ ตัวชี้ไปยังฮีปจะต้องถูกลบด้วยตนเองหลังจากที่ไม่ต้องการใช้อีกต่อไป หากไม่เป็นเช่นนั้น หน่วยความจำนั้นจะใช้งานไม่ได้เมื่อตัวชี้สุดท้ายหลุดจากขอบเขต และจะไม่ถูกกู้คืนจนกว่าระบบปฏิบัติการจะจัดการมันเมื่อกระบวนการสิ้นสุด สำนวนสมัยใหม่ C++ จะใช้ unique_ptr ที่นี่ ซึ่งนำพฤติกรรมที่ต้องการไปใช้ จะลบวัตถุที่ชี้ไปเมื่อตัวชี้หลุดจากขอบเขต อย่างไรก็ตาม ลักษณะการทำงานนั้นไม่ได้เป็นส่วนหนึ่งของภาษาจนถึง C++11

ในตัวอย่างนี้ สามารถแก้ไขได้ง่าย:

 vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines = new vector<string>; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); vector<string> * file_lines = read_lines_from_file(file_name); int count = file_lines->size(); delete file_lines; cout << "File " << file_name << " contains " << count << " lines."; return 0; }

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

ที่เกี่ยวข้อง: การดีบักหน่วยความจำรั่วไหลใน Node.js Applications

“สู่คนเก็บขยะ ฟลายบอย!”

เครื่องเก็บขยะไม่ใช่เทคโนโลยีใหม่ พวกเขาถูกประดิษฐ์ขึ้นในปี 2502 โดย John McCarthy สำหรับ Lisp Smalltalk-80 ในปี 1980 การเก็บขยะเริ่มเข้ามาสู่กระแสหลัก อย่างไรก็ตาม ทศวรรษ 1990 แสดงถึงการออกดอกของเทคนิคอย่างแท้จริง: ระหว่างปี 1990 ถึง 2000 มีการเปิดตัวภาษาจำนวนมาก ซึ่งทั้งหมดใช้การรวบรวมขยะไม่ทางใดก็ทางหนึ่ง: Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml และ C# เป็นที่รู้จักกันดี

การเก็บขยะคืออะไร? กล่าวโดยย่อ เป็นชุดของเทคนิคที่ใช้ในการทำให้การจัดการหน่วยความจำแบบแมนนวลเป็นแบบอัตโนมัติ มักใช้เป็นไลบรารี่สำหรับภาษาที่มีการจัดการหน่วยความจำด้วยตนเอง เช่น C และ C++ แต่มักใช้ในภาษาที่ต้องการ ข้อได้เปรียบที่ยอดเยี่ยมคือโปรแกรมเมอร์ไม่จำเป็นต้องคิดถึงหน่วยความจำ มันถูกแยกออกไปทั้งหมด ตัวอย่างเช่น Python เทียบเท่ากับโค้ดอ่านไฟล์ของเราด้านบนนี้:

 def read_lines_from_file(file_name): lines = [] with open(file_name) as fp: for line in fp: lines.append(line) return lines if __name__ == '__main__': import sys file_name = sys.argv[1] count = len(read_lines_from_file(file_name)) print("File {} contains {} lines.".format(file_name, count))
 $ python3 python3.py makefile File makefile contains 38 lines.

อาร์เรย์ของบรรทัดจะเกิดขึ้นเมื่อมีการกำหนดและส่งคืนโดยไม่ต้องคัดลอกไปยังขอบเขตการเรียก Garbage Collector จะถูกทำความสะอาดหลังจากที่มันหลุดออกจากขอบเขตนั้น เนื่องจากเวลาไม่แน่นอน ข้อสังเกตที่น่าสนใจคือใน Python RAII สำหรับทรัพยากรที่ไม่ใช่หน่วยความจำไม่ใช่สำนวน ได้รับอนุญาต - เราสามารถเขียนเพียงแค่ fp = open(file_name) แทนที่จะใช้บล็อกที่ with และปล่อยให้ GC ล้างข้อมูลในภายหลัง แต่รูปแบบที่แนะนำคือการใช้ตัวจัดการบริบทเมื่อเป็นไปได้ เพื่อให้สามารถเผยแพร่ได้ในเวลาที่กำหนด

ดีเท่าที่เป็นนามธรรมออกไปการจัดการหน่วยความจำมีค่าใช้จ่าย ในการนับจำนวนขยะที่อ้างอิง การกำหนดตัวแปรและการออกขอบเขตทั้งหมดจะมีต้นทุนเพียงเล็กน้อยในการอัพเดทข้อมูลอ้างอิง ในระบบ mark-and-sweep การทำงานของโปรแกรมทั้งหมดจะหยุดลงในขณะที่ GC ล้างหน่วยความจำในช่วงเวลาที่คาดเดาไม่ได้ นี้มักจะเรียกว่าเหตุการณ์หยุดโลก การใช้งาน เช่น Python ซึ่งใช้ทั้งสองระบบ จะได้รับโทษทั้งสองอย่าง ปัญหาเหล่านี้ลดความเหมาะสมของภาษาที่รวบรวมโดยขยะในกรณีที่ประสิทธิภาพเป็นสิ่งสำคัญ หรือจำเป็นต้องใช้แอปพลิเคชันแบบเรียลไทม์ เราสามารถเห็นบทลงโทษในการดำเนินการแม้ในโปรแกรมของเล่นเหล่านี้:

 $ make cpp && time ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines. real 0m0.016s user 0m0.000s sys 0m0.015s $ time python3 python3.py makefile File makefile contains 38 lines. real 0m0.041s user 0m0.015s sys 0m0.015s

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

กรรมสิทธิ์: RAII Awakens

นั่นคือจุดสิ้นสุดแล้ว? ภาษาโปรแกรมทั้งหมดต้องเลือกระหว่างประสิทธิภาพและความสะดวกในการเขียนโปรแกรมหรือไม่ ไม่! การวิจัยภาษาเชิงโปรแกรมยังคงดำเนินต่อไป และเราเริ่มเห็นการใช้งานครั้งแรกของกระบวนทัศน์ภาษารุ่นต่อไป สิ่งที่น่าสนใจเป็นพิเศษคือภาษาที่เรียกว่า Rust ซึ่งรับประกันการยศาสตร์เหมือน Python และความเร็วเหมือน C ในขณะที่ทำพอยน์เตอร์ที่ห้อยต่องแต่ง ตัวชี้ว่าง และเป็นไปไม่ได้เช่นนั้น - พวกมันจะไม่คอมไพล์ จะอ้างสิทธิ์เหล่านั้นได้อย่างไร?

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

กรรมสิทธิ์

ในการสนทนาของเราเกี่ยวกับพอยน์เตอร์ใน C ++ เราได้สัมผัสกับแนวคิดของการเป็นเจ้าของ ซึ่งในแง่คร่าวๆ หมายถึง "ใครเป็นผู้รับผิดชอบในการลบตัวแปรนี้" สนิมทำให้แนวคิดนี้เป็นทางการและเสริมความแข็งแกร่ง การเชื่อมโยงตัวแปรทุกตัวมีความเป็นเจ้าของในทรัพยากรที่ผูกไว้ และตัวตรวจสอบการยืมจะทำให้แน่ใจว่ามีการเชื่อมโยงเดียวที่มีความเป็นเจ้าของโดยรวมของทรัพยากร นั่นคือตัวอย่างต่อไปนี้จาก Rust Book จะไม่คอมไพล์:

 let v = vec![1, 2, 3]; let v2 = v; println!("v[0] is: {}", v[0]);
 error: use of moved value: `v` println!("v[0] is: {}", v[0]); ^

Assignments ใน Rust ได้ย้ายความหมายโดยค่าเริ่มต้น - พวกเขาโอนความเป็นเจ้าของ เป็นไปได้ที่จะกำหนดความหมายของการคัดลอกให้กับประเภท และสิ่งนี้ได้ทำไปแล้วสำหรับพื้นฐานที่เป็นตัวเลข แต่ก็ไม่ปกติ ด้วยเหตุนี้ ในโค้ดบรรทัดที่สาม v2 จึงเป็นเจ้าของเวกเตอร์ที่เป็นปัญหาและไม่สามารถเข้าถึงได้ในชื่อ v อีกต่อไป เหตุใดจึงมีประโยชน์ เมื่อทุกทรัพยากรมีเจ้าของเพียงคนเดียว มันก็มีช่วงเวลาเดียวที่ทรัพยากรนั้นไม่อยู่ในขอบเขต ซึ่งสามารถกำหนดได้ในเวลารวบรวม ซึ่งหมายความว่า Rust สามารถส่งมอบตามสัญญาของ RAII ได้ เริ่มต้นและทำลายทรัพยากรโดยกำหนดตามขอบเขตของทรัพยากร โดยไม่ต้องใช้ตัวรวบรวมขยะหรือต้องการให้โปรแกรมเมอร์ปล่อยสิ่งใด ๆ ด้วยตนเอง

เปรียบเทียบสิ่งนี้กับการรวบรวมขยะที่นับการอ้างอิง ในการใช้งาน RC ตัวชี้ทั้งหมดมีข้อมูลอย่างน้อยสองส่วน: วัตถุที่ชี้ไปที่ และจำนวนการอ้างอิงไปยังวัตถุนั้น ออบเจ็กต์จะถูกทำลายเมื่อจำนวนนั้นถึง 0 ซึ่งจะเป็นการเพิ่มความต้องการหน่วยความจำของตัวชี้เป็นสองเท่าและเพิ่มค่าใช้จ่ายเล็กน้อยในการใช้งาน เนื่องจากการนับจะเพิ่มขึ้น ลดค่า และตรวจสอบโดยอัตโนมัติ ระบบความเป็นเจ้าของของ Rust ให้การรับประกันแบบเดียวกัน วัตถุจะถูกทำลายโดยอัตโนมัติเมื่อไม่มีการอ้างอิง แต่จะทำเช่นนั้นโดยไม่มีค่าใช้จ่ายรันไทม์ มีการวิเคราะห์ความเป็นเจ้าของของแต่ละอ็อบเจ็กต์และการเรียกการทำลายที่แทรกในเวลารวบรวม

การยืม

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

 fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) { // do stuff with v1 and v2 // hand back ownership, and the result of our function (v1, v2, 42) } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let (v1, v2, answer) = foo(v1, v2);

Rust มีแนวคิดเรื่องการกู้ยืมแทน คุณสามารถเขียนฟังก์ชันเดียวกันได้ และมันจะยืมการอ้างอิงไปยังเวกเตอร์ ส่งคืนเจ้าของเมื่อฟังก์ชันสิ้นสุด:

 fn foo(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 { // do stuff 42 } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let answer = foo(&v1, &v2);

v1 และ v2 คืนความเป็นเจ้าของไปยังขอบเขตเดิมหลังจาก fn foo กลับมา หลุดออกจากขอบเขตและถูกทำลายโดยอัตโนมัติเมื่อขอบเขตที่มีอยู่ออก

เป็นมูลค่าการกล่าวขวัญที่นี่ว่ามีข้อ จำกัด ในการยืมซึ่งบังคับใช้โดยตัวตรวจสอบการยืมในเวลารวบรวมซึ่ง Rust Book ระบุไว้อย่างกระชับ:

เงินกู้ใด ๆ จะต้องมีอายุไม่เกินขอบเขตของเจ้าของ ประการที่สอง คุณอาจมีเงินกู้ประเภทใดประเภทหนึ่งจากสองประเภทนี้ แต่ไม่ใช่ทั้งสองอย่างพร้อมกัน:

การอ้างอิง (&T) อย่างน้อยหนึ่งรายการไปยังทรัพยากร

การอ้างอิงที่ไม่แน่นอนหนึ่งรายการ (&mut T)

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

ตัวตรวจสอบการยืมในแง่การปฏิบัติ

ตอนนี้เรารู้เกี่ยวกับคุณสมบัติบางอย่างของ Rust แล้ว มาดูวิธีที่เราใช้ตัวนับบรรทัดไฟล์เดียวกันกับที่เราเคยเห็นมาก่อน:

 fn read_lines_from_file(file_name: &str) -> io::Result<Vec<String>> { // variables in Rust are immutable by default. The mut keyword allows them to be mutated. let mut lines = Vec::new(); let mut buffer = String::new(); if let Ok(mut fp) = OpenOptions::new().read(true).open(file_name) { // We enter this block only if the file was successfully opened. // This is one way to unwrap the Result<T, E> type Rust uses instead of exceptions. // fp.read_to_string can return an Err. The try! macro passes such errors // upwards through the call stack, or continues otherwise. try!(fp.read_to_string(&mut buffer)); lines = buffer.split("\n").map(|s| s.to_string()).collect(); } Ok(lines) } fn main() { // Get file name from the first argument. // Note that args().nth() produces an Option<T>. To get at the actual argument, we use // the .expect() function, which panics with the given message if nth() returned None, // indicating that there weren't at least that many arguments. Contrast with C++, which // segfaults when there aren't enough arguments, or Python, which raises an IndexError. // In Rust, error cases *must* be accounted for. let file_name = env::args().nth(1).expect("This program requires at least one argument!"); if let Ok(file_lines) = read_lines_from_file(&file_name) { println!("File {} contains {} lines.", file_name, file_lines.len()); } else { // read_lines_from_file returned an error println!("Could not read file {}", file_name); } }

นอกเหนือจากรายการที่แสดงความคิดเห็นแล้วในซอร์สโค้ดแล้ว ก็ยังคุ้มค่าที่จะตรวจสอบและติดตามอายุขัยของตัวแปรต่างๆ file_name และ file_lines จนถึงจุดสิ้นสุดของ main(); ตัวทำลายล้างของพวกเขาถูกเรียกในเวลานั้นโดยไม่มีค่าใช้จ่ายเพิ่มเติม โดยใช้กลไกเดียวกันกับตัวแปรอัตโนมัติของ C++ เมื่อเรียก read_lines_from_file file_name จะถูกยืมไปยังฟังก์ชันนั้นอย่างไม่เปลี่ยนแปลงตลอดระยะเวลา ภายใน read_lines_from_file buffer จะทำหน้าที่เช่นเดียวกัน ซึ่งจะถูกทำลายเมื่ออยู่นอกขอบเขต ในทางกลับกัน lines ยังคงมีอยู่และส่งคืนไปยัง main ได้สำเร็จ ทำไม?

สิ่งแรกที่ควรทราบก็คือ เนื่องจาก Rust เป็นภาษาที่ใช้นิพจน์ การเรียกกลับอาจดูไม่เหมือนในตอนแรก ถ้าบรรทัดสุดท้ายของฟังก์ชันละเว้นเซมิโคลอนต่อท้าย นิพจน์นั้นจะเป็นค่าส่งกลับ สิ่งที่สองคือค่าที่ส่งคืนจะได้รับการจัดการพิเศษ ถือว่าพวกเขาต้องการที่จะมีชีวิตอยู่อย่างน้อยตราบเท่าที่ผู้โทรของฟังก์ชัน หมายเหตุสุดท้ายคือเนื่องจากความหมายของการย้ายที่เกี่ยวข้อง ไม่จำเป็นต้องคัดลอกเพื่อแปลง Ok(lines) เป็น Ok(file_lines) คอมไพเลอร์เพียงแค่ทำให้ตัวแปรชี้ไปที่บิตของหน่วยความจำที่เหมาะสม

“ในตอนท้ายคุณตระหนักถึงพลังที่แท้จริงของ RAII เท่านั้น”

การจัดการหน่วยความจำด้วยตนเองเป็นฝันร้ายที่โปรแกรมเมอร์ได้คิดค้นวิธีหลีกเลี่ยงตั้งแต่มีการประดิษฐ์คอมไพเลอร์ RAII เป็นรูปแบบที่มีแนวโน้มดี แต่พิการใน C ++ เนื่องจากใช้งานไม่ได้กับอ็อบเจ็กต์ที่จัดสรรแบบฮีปโดยไม่มีวิธีแก้ไขปัญหาเฉพาะหน้า ดังนั้นจึงเกิดการระเบิดขึ้นของภาษาที่เก็บขยะในทศวรรษ 90 ซึ่งได้รับการออกแบบมาเพื่อให้ชีวิตโปรแกรมเมอร์น่าอยู่ยิ่งขึ้นแม้จะต้องเสียประสิทธิภาพการทำงานก็ตาม

อย่างไรก็ตาม นั่นไม่ใช่คำสุดท้ายในการออกแบบภาษา ด้วยการใช้แนวคิดใหม่และชัดเจนในการเป็นเจ้าของและการยืม Rust สามารถรวมขอบเขตพื้นฐานของรูปแบบ RAII เข้ากับความปลอดภัยของหน่วยความจำของการรวบรวมขยะ โดยไม่ต้องให้คนเก็บขยะหยุดโลก ในขณะที่รับประกันความปลอดภัยที่ไม่เคยเห็นในภาษาอื่น นี่คืออนาคตของการเขียนโปรแกรมระบบ ท้ายที่สุด “การทำผิดคือมนุษย์ แต่คอมไพเลอร์ไม่เคยลืม”


อ่านเพิ่มเติมในบล็อก Toptal Engineering:

  • WebAssembly/Rust Tutorial: การประมวลผลเสียงที่สมบูรณ์แบบ
  • ล่าหน่วยความจำรั่วใน Java