ทำไมเราลอกแล้วย้าย?


98

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

struct S
{
    S(std::string str) : data(std::move(str))
    {}
};

นี่คือคำถามของฉัน:

  • ทำไมเราไม่ใช้ rvalue-reference str?
  • สำเนาจะไม่แพงโดยเฉพาะอย่างยิ่งเช่นstd::string?
  • อะไรคือสาเหตุที่ทำให้ผู้เขียนตัดสินใจทำสำเนาแล้วย้าย?
  • เมื่อไหร่ที่ควรทำด้วยตัวเอง?

ดูเหมือนจะเป็นความผิดพลาดโง่ ๆ สำหรับฉัน แต่ฉันจะสนใจที่จะดูว่าคนที่มีความรู้มากกว่านี้มีอะไรจะพูดเกี่ยวกับเรื่องนี้หรือไม่
Dave


คำถาม & คำตอบนี้ตอนแรกฉันลืมเชื่อมโยงอาจเกี่ยวข้องกับหัวข้อนี้ด้วย
Andy Prowl

คำตอบ:


97

ก่อนที่ฉันจะตอบคำถามของคุณสิ่งหนึ่งที่คุณดูเหมือนจะผิด: การรับค่าใน C ++ 11 ไม่ได้หมายถึงการคัดลอกเสมอไป หากค่า rvalue ถูกส่งไปค่านั้นจะถูกย้าย (หากมีตัวสร้างการย้ายที่ทำงานได้) แทนที่จะถูกคัดลอก และstd::stringมีตัวสร้างการย้าย

ไม่เหมือนใน C ++ 03 ใน C ++ 11 มักเป็นสำนวนที่จะใช้พารามิเตอร์ตามค่าด้วยเหตุผลที่ฉันจะอธิบายด้านล่าง ดูถาม & ตอบนี้บน StackOverflowสำหรับชุดแนวทางทั่วไปเพิ่มเติมเกี่ยวกับวิธียอมรับพารามิเตอร์

ทำไมเราไม่ใช้ rvalue-reference str?

เนื่องจากจะทำให้ไม่สามารถส่งผ่านค่าต่างๆได้เช่นใน:

std::string s = "Hello";
S obj(s); // s is an lvalue, this won't compile!

หากSมีเพียงตัวสร้างที่ยอมรับ rvalues ​​ข้างต้นจะไม่คอมไพล์

สำเนาจะไม่แพงโดยเฉพาะอย่างยิ่งเช่นstd::string?

หากคุณผ่านการ rvalue ที่จะย้ายเข้ามาและที่จะได้รับการย้ายเข้ามาอยู่str dataจะไม่มีการคัดลอก หากคุณผ่านการ lvalue บนมืออื่น ๆ ที่ lvalue ที่จะถูกคัดลอกลงในและจากนั้นย้ายเข้ามาอยู่strdata

ดังนั้นเพื่อสรุปผลการเคลื่อนไหวสองครั้งสำหรับ rvalues ​​หนึ่งสำเนาและหนึ่งการย้ายสำหรับค่า lvalues

อะไรคือสาเหตุที่ทำให้ผู้เขียนตัดสินใจทำสำเนาแล้วย้าย?

ก่อนอื่นตามที่ฉันได้กล่าวไว้ข้างต้นอันแรกไม่ใช่สำเนาเสมอไป และสิ่งนี้กล่าวว่าคำตอบคือ: " เนื่องจากมีประสิทธิภาพ (การเคลื่อนย้ายstd::stringวัตถุมีราคาถูก) และเรียบง่าย "

ภายใต้สมมติฐานว่าการเคลื่อนไหวมีราคาถูก (โดยไม่สนใจ SSO ที่นี่) พวกเขาสามารถมองข้ามไปได้ในทางปฏิบัติเมื่อพิจารณาถึงประสิทธิภาพโดยรวมของการออกแบบนี้ หากเราทำเช่นนั้นเราจะมีสำเนาหนึ่งชุดสำหรับ lvalues ​​(ตามที่เราจะได้รับหากเรายอมรับการอ้างอิง lvalue ถึงconst) และไม่มีสำเนาสำหรับ rvalues ​​(ในขณะที่เรายังคงมีสำเนาหากเรายอมรับการอ้างอิง lvalue const)

ซึ่งหมายความว่าการรับตามค่าจะดีพอ ๆ กับการอ้างอิง lvalue constเมื่อมีการระบุ lvalues ​​และจะดีกว่าเมื่อมีการระบุค่า rvalues

PS: เพื่อให้บริบทบางอย่างฉันเชื่อว่านี่คือคำถาม & คำตอบที่ OP อ้างถึง


2
ควรกล่าวถึงว่าเป็นรูปแบบ C ++ 11 ที่แทนที่const T&อาร์กิวเมนต์ที่ส่งผ่าน: ในกรณีที่เลวร้ายที่สุด (lvalue) จะเหมือนกัน แต่ในกรณีชั่วคราวคุณจะต้องย้ายชั่วคราวเท่านั้น ชนะ - ชนะ
syam

3
@ user2030677: ไม่มีการหลีกเลี่ยงสำเนานั้นเว้นแต่คุณจะจัดเก็บข้อมูลอ้างอิง
Benjamin Lindley

5
@ user2030677: ใครสนใจว่าสำเนาจะแพงแค่ไหนตราบเท่าที่คุณต้องการ (และคุณทำถ้าคุณต้องการเก็บสำเนาไว้ในdataสมาชิกของคุณ)? คุณจะมีสำเนาแม้ว่าคุณจะใช้โดยอ้างอิง lvalue ถึงconst
Andy Prowl

3
@BenjaminLindley: ในเบื้องต้นฉันเขียนว่า: " ภายใต้สมมติฐานว่าการเคลื่อนไหวมีราคาถูกพวกเขาสามารถมองข้ามไปได้ในทางปฏิบัติเมื่อพิจารณาถึงประสิทธิภาพโดยรวมของการออกแบบนี้ " ใช่จะมีค่าใช้จ่ายในการเคลื่อนย้าย แต่ควรพิจารณาเล็กน้อยเว้นแต่จะมีข้อพิสูจน์ว่านี่เป็นข้อกังวลที่แท้จริงซึ่งแสดงให้เห็นถึงการเปลี่ยนการออกแบบที่เรียบง่ายเป็นสิ่งที่มีประสิทธิภาพมากขึ้น
Andy Prowl

1
@ user2030677: แต่นั่นเป็นตัวอย่างที่แตกต่างอย่างสิ้นเชิง ในตัวอย่างจากคำถามของคุณคุณมักจะถือสำเนาไว้data!
Andy Prowl

51

เพื่อให้เข้าใจว่าเหตุใดจึงเป็นรูปแบบที่ดีเราควรตรวจสอบทางเลือกทั้งใน C ++ 03 และใน C ++ 11

เรามีวิธีการ C ++ 03 ในการstd::string const&:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str)
  {}
};

ในกรณีนี้จะมีเสมอจะเป็นสำเนาเดียวดำเนินการ หากคุณสร้างจากสตริง C ดิบ a std::stringจะถูกสร้างจากนั้นคัดลอกอีกครั้ง: สองการปันส่วน

มีวิธี C ++ 03 ในการอ้างอิงถึง a std::stringจากนั้นเปลี่ยนเป็นโลคัลstd::string:

struct S
{
  std::string data; 
  S(std::string& str)
  {
    std::swap(data, str);
  }
};

นั่นคือ "move semantics" เวอร์ชัน C ++ 03 และswapมักจะได้รับการปรับให้มีราคาถูกมาก (เหมือน a move) นอกจากนี้ควรวิเคราะห์ในบริบท:

S tmp("foo"); // illegal
std::string s("foo");
S tmp2(s); // legal

และบังคับให้คุณสร้างแบบไม่ชั่วคราวstd::stringจากนั้นทิ้งไป (ชั่วคราวstd::stringไม่สามารถผูกกับการอ้างอิงที่ไม่ใช่ const) อย่างไรก็ตามการจัดสรรเพียงรายการเดียวเท่านั้น เวอร์ชัน C ++ 11 จะใช้เวลา&&และกำหนดให้คุณต้องเรียกใช้ด้วยstd::moveหรือชั่วคราว: สิ่งนี้ต้องการให้ผู้โทรสร้างสำเนานอกการโทรอย่างชัดเจนและย้ายสำเนานั้นไปยังฟังก์ชันหรือตัวสร้าง

struct S
{
  std::string data; 
  S(std::string&& str): data(std::move(str))
  {}
};

ใช้:

S tmp("foo"); // legal
std::string s("foo");
S tmp2(std::move(s)); // legal

ต่อไปเราสามารถทำ C ++ 11 เวอร์ชันเต็มซึ่งรองรับทั้งการคัดลอกและmove:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str) {} // lvalue const, copy
  S(std::string && str) : data(std::move(str)) {} // rvalue, move
};

จากนั้นเราสามารถตรวจสอบวิธีใช้:

S tmp( "foo" ); // a temporary `std::string` is created, then moved into tmp.data

std::string bar("bar"); // bar is created
S tmp2( bar ); // bar is copied into tmp.data

std::string bar2("bar2"); // bar2 is created
S tmp3( std::move(bar2) ); // bar2 is moved into tmp.data

ค่อนข้างชัดเจนว่าเทคนิคโอเวอร์โหลด 2 อย่างนี้มีประสิทธิภาพอย่างน้อยที่สุดถ้าไม่มากไปกว่า C ++ 03 สองสไตล์ข้างต้น ฉันจะพากย์ 2-overload เวอร์ชันนี้เป็นเวอร์ชันที่ "เหมาะสมที่สุด"

ตอนนี้เราจะตรวจสอบเวอร์ชันถ่ายโดยสำเนา:

struct S2 {
  std::string data;
  S2( std::string arg ):data(std::move(x)) {}
};

ในแต่ละสถานการณ์เหล่านั้น:

S2 tmp( "foo" ); // a temporary `std::string` is created, moved into arg, then moved into S2::data

std::string bar("bar"); // bar is created
S2 tmp2( bar ); // bar is copied into arg, then moved into S2::data

std::string bar2("bar2"); // bar2 is created
S2 tmp3( std::move(bar2) ); // bar2 is moved into arg, then moved into S2::data

หากคุณเปรียบเทียบสิ่งนี้แบบเคียงข้างกันกับเวอร์ชันที่ "เหมาะสมที่สุด" เราจะทำเพิ่มเติมอีกหนึ่งข้อmove! เราไม่ได้ทำพิเศษเพียงครั้งเดียวcopyไม่ใช่แค่ครั้งเดียวเราจะทำพิเศษ

ดังนั้นถ้าเราสมมติว่า moveราคาถูกเวอร์ชันนี้จะทำให้เรามีประสิทธิภาพใกล้เคียงกับเวอร์ชันที่เหมาะสมที่สุด แต่มีโค้ดน้อยกว่า 2 เท่า

และถ้าคุณกำลังพูด 2 ถึง 10 อาร์กิวเมนต์การลดโค้ดจะเป็นเลขชี้กำลัง - น้อยกว่า 2 เท่าโดยมี 1 อาร์กิวเมนต์ 4x กับ 2, 8x กับ 3, 16x กับ 4, 1024x พร้อมด้วยอาร์กิวเมนต์ 10

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

และฟังก์ชันที่สร้างขึ้นจำนวนมากหมายถึงขนาดโค้ดที่ปฏิบัติการได้ใหญ่ขึ้นซึ่งสามารถลดประสิทธิภาพได้

สำหรับค่าใช้จ่ายไม่กี่ moveวินาทีเราได้รับโค้ดที่สั้นลงและประสิทธิภาพใกล้เคียงกันและมักจะเข้าใจรหัสได้ง่ายขึ้น

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

ข้อดีอีกประการหนึ่งของเทคนิค "take by value" คือบ่อยครั้งที่การย้ายตัวสร้างนั้นไม่มีข้อยกเว้นนั่นหมายความว่าฟังก์ชันที่รับค่าตามค่าและย้ายออกจากอาร์กิวเมนต์มักจะไม่มีข้อยกเว้นการย้ายthrows ใด ๆออกจากร่างกายและไปยังขอบเขตการเรียก (ผู้ที่สามารถหลีกเลี่ยงได้ผ่านการก่อสร้างโดยตรงในบางครั้งหรือสร้างสิ่งของและmoveเข้าสู่การโต้แย้งเพื่อควบคุมว่าจะเกิดการขว้างปาที่ใด) การสร้างวิธีการไม่โยนมักจะคุ้มค่า


ฉันจะเพิ่มถ้าเรารู้ว่าเราจะทำสำเนาเราควรปล่อยให้คอมไพเลอร์ทำเพราะคอมไพเลอร์รู้ดีกว่าเสมอ
Rayniery

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

ทำไมคุณถึงต้องการเวอร์ชัน "lvalue non-const, copy" ในเทคนิค 3 overload? "lvalue const, copy" จัดการกรณี non const ด้วยหรือไม่
Bruno Martinez

@BrunoMartinez เราไม่!
Yakk - Adam Nevraumont

13

นี้อาจจะเป็นความตั้งใจและมีความคล้ายคลึงกับการคัดลอกและแลกเปลี่ยนสำนวน โดยพื้นฐานแล้วเนื่องจากสตริงถูกคัดลอกก่อนตัวสร้างตัวสร้างเองจึงมีข้อยกเว้นที่ปลอดภัยเนื่องจากจะสลับ (ย้าย) สตริงสตริงชั่วคราวเท่านั้น


+1 สำหรับ copy-and-swap parallel อันที่จริงมันมีความคล้ายคลึงกันมาก
syam

11

คุณไม่ต้องการทำซ้ำตัวเองโดยการเขียนตัวสร้างสำหรับการย้ายและอีกอันสำหรับสำเนา:

S(std::string&& str) : data(std::move(str)) {}
S(const std::string& str) : data(str) {}

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

สำนวนที่แข่งขันกันคือการใช้การส่งต่อที่สมบูรณ์แบบ:

template <typename T>
S(T&& str) : data(std::forward<T>(str)) {}

เวทมนตร์แม่แบบจะเลือกย้ายหรือคัดลอกขึ้นอยู่กับพารามิเตอร์ที่คุณส่งผ่านโดยพื้นฐานแล้วจะขยายไปยังเวอร์ชันแรกโดยที่ตัวสร้างทั้งสองเขียนด้วยมือ สำหรับข้อมูลพื้นฐานโปรดดูโพสต์ของ Scott Meyer เกี่ยวกับการอ้างอิงสากลการอ้างอิงสากล

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

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.