Làm thế nào để truyền các tham số một cách chính xác?


108

Tôi là người mới bắt đầu C ++ nhưng không phải là người mới bắt đầu lập trình. Tôi đang cố gắng học C ++ (c ++ 11) và điều quan trọng nhất đối với tôi là không rõ ràng: truyền tham số.

Tôi đã xem xét những ví dụ đơn giản sau:

  • Một lớp có tất cả các thành viên của nó là các kiểu nguyên thủy:
    CreditCard(std::string number, int expMonth, int expYear,int pin):number(number), expMonth(expMonth), expYear(expYear), pin(pin)

  • Một lớp có thành viên là các kiểu nguyên thủy + 1 kiểu phức tạp:
    Account(std::string number, float amount, CreditCard creditCard) : number(number), amount(amount), creditCard(creditCard)

  • Một lớp có thành viên là các kiểu nguyên thủy + 1 bộ sưu tập của một số kiểu phức tạp: Client(std::string firstName, std::string lastName, std::vector<Account> accounts):firstName(firstName), lastName(lastName), accounts(accounts)

Khi tôi tạo một tài khoản, tôi làm như sau:

    CreditCard cc("12345",2,2015,1001);
    Account acc("asdasd",345, cc);

Rõ ràng là thẻ tín dụng sẽ được sao chép hai lần trong trường hợp này. Nếu tôi viết lại hàm tạo đó thành

Account(std::string number, float amount, CreditCard& creditCard) 
    : number(number)
    , amount(amount)
    , creditCard(creditCard)

sẽ có một bản sao. Nếu tôi viết lại nó là

Account(std::string number, float amount, CreditCard&& creditCard) 
    : number(number)
    , amount(amount)
    , creditCard(std::forward<CreditCard>(creditCard))

Sẽ có 2 lần di chuyển và không có bản sao.

Tôi nghĩ rằng đôi khi bạn có thể muốn sao chép một số tham số, đôi khi bạn không muốn sao chép khi bạn tạo đối tượng đó.
Tôi đến từ C # và, được sử dụng để tham chiếu, nó hơi lạ đối với tôi và tôi nghĩ rằng nên có 2 quá tải cho mỗi tham số nhưng tôi biết mình đã sai.
Có bất kỳ phương pháp hay nhất nào về cách gửi tham số trong C ++ không vì tôi thực sự tìm thấy nó, giả sử, không hề tầm thường. Làm thế nào bạn sẽ xử lý các ví dụ của tôi được trình bày ở trên?


9
Meta: Tôi không thể tin rằng ai đó vừa hỏi một câu hỏi C ++ hay. +1.

23
FYI, std::stringlà một lớp, giống như CreditCard, không phải là một kiểu nguyên thủy.
chris

7
Do sự nhầm lẫn chuỗi, mặc dù không liên quan, bạn nên biết rằng một chuỗi theo nghĩa đen, "abc"không thuộc kiểu std::string, không phải kiểu char */const char *, mà là kiểu const char[N](với N = 4 trong trường hợp này là do ba ký tự và null). Đó là một quan niệm sai lầm phổ biến để tránh xa.
chris

10
@chuex: Jon Skeet là câu hỏi về C #, hiếm hơn là C ++.
Steve Jessop

Câu trả lời:


158

CÂU HỎI QUAN TRỌNG NHẤT ĐẦU TIÊN:

Có bất kỳ phương pháp hay nhất nào về cách gửi tham số trong C ++ không vì tôi thực sự tìm thấy nó, giả sử, không hề tầm thường

Nếu hàm của bạn cần sửa đổi đối tượng gốc đang được truyền, để sau khi cuộc gọi trả về, các sửa đổi đối với đối tượng đó sẽ được hiển thị cho người gọi, thì bạn nên chuyển bằng tham chiếu giá trị :

void foo(my_class& obj)
{
    // Modify obj here...
}

Nếu hàm của bạn không cần sửa đổi đối tượng gốc và không cần tạo bản sao của nó (nói cách khác, nó chỉ cần quan sát trạng thái của nó), thì bạn nên chuyển bằng tham chiếu giá trị đếnconst :

void foo(my_class const& obj)
{
    // Observe obj here
}

Điều này sẽ cho phép bạn gọi hàm với cả giá trị (giá trị là đối tượng có danh tính ổn định) và với giá trị (giá trị, ví dụ: giá trị tạm thời hoặc đối tượng bạn sắp di chuyển từ kết quả của việc gọi std::move()).

Người ta cũng có thể tranh luận rằng đối với các kiểu hoặc kiểu cơ bản mà việc sao chép diễn ra nhanh chóng , chẳng hạn như int, boolhoặc char, không cần chuyển qua tham chiếu nếu hàm chỉ cần quan sát giá trị và chuyển theo giá trị nên được ưu tiên . Điều đó đúng nếu ngữ nghĩa tham chiếu là không cần thiết, nhưng điều gì sẽ xảy ra nếu hàm muốn lưu trữ một con trỏ đến đối tượng đầu vào giống nhau đó ở đâu đó, để sau này đọc qua con trỏ đó sẽ thấy các sửa đổi giá trị đã được thực hiện trong một số phần khác của mã? Trong trường hợp này, chuyển qua tham chiếu là giải pháp chính xác.

Nếu hàm của bạn không cần sửa đổi đối tượng gốc, nhưng cần lưu trữ một bản sao của đối tượng đó ( có thể trả về kết quả của một phép biến đổi đầu vào mà không làm thay đổi đầu vào ), thì bạn có thể xem xét lấy theo giá trị :

void foo(my_class obj) // One copy or one move here, but not working on
                       // the original object...
{
    // Working on obj...

    // Possibly move from obj if the result has to be stored somewhere...
}

Việc gọi hàm trên sẽ luôn dẫn đến một bản sao khi chuyển các giá trị và trong một lần di chuyển khi chuyển các giá trị. Nếu hàm của bạn cần lưu trữ đối tượng này ở đâu đó, bạn có thể thực hiện thêm một lần di chuyển từ nó (ví dụ: trong trường hợp foo()một hàm thành viên cần lưu trữ giá trị trong một thành viên dữ liệu ).

Trong trường hợp việc di chuyển là tốn kém đối với các đối tượng cùng loại my_class, thì bạn có thể cân nhắc nạp chồng foo()và cung cấp một phiên bản cho giá trị (chấp nhận tham chiếu giá trị tới const) và một phiên bản cho giá trị (chấp nhận tham chiếu giá trị ):

// Overload for lvalues
void foo(my_class const& obj) // No copy, no move (just reference binding)
{
    my_class copyOfObj = obj; // Copy!
    // Working on copyOfObj...
}

// Overload for rvalues
void foo(my_class&& obj) // No copy, no move (just reference binding)
{
    my_class copyOfObj = std::move(obj); // Move! 
                                         // Notice, that invoking std::move() is 
                                         // necessary here, because obj is an
                                         // *lvalue*, even though its type is 
                                         // "rvalue reference to my_class".
    // Working on copyOfObj...
}

Trên thực tế, các hàm trên rất giống nhau, đến mức bạn có thể tạo ra một hàm duy nhất từ ​​nó: foo()có thể trở thành một mẫu hàm và bạn có thể sử dụng chuyển tiếp hoàn hảo để xác định xem một động thái hay một bản sao của đối tượng đang được chuyển sẽ được tạo nội bộ:

template<typename C>
void foo(C&& obj) // No copy, no move (just reference binding)
//       ^^^
//       Beware, this is not always an rvalue reference! This will "magically"
//       resolve into my_class& if an lvalue is passed, and my_class&& if an
//       rvalue is passed
{
    my_class copyOfObj = std::forward<C>(obj); // Copy if lvalue, move if rvalue
    // Working on copyOfObj...
}

Bạn có thể muốn tìm hiểu thêm về thiết kế này bằng cách xem bài nói chuyện này của Scott Meyers (chỉ cần lưu ý rằng thuật ngữ " Tài liệu tham khảo chung " mà anh ấy đang sử dụng là không chuẩn).

Một điều cần lưu ý là std::forwardthường sẽ kết thúc bằng một lần di chuyển cho các giá trị, vì vậy mặc dù nó trông tương đối vô tội, việc chuyển tiếp cùng một đối tượng nhiều lần có thể là một nguồn rắc rối - ví dụ: di chuyển từ cùng một đối tượng hai lần! Vì vậy, hãy cẩn thận không đặt điều này trong một vòng lặp và không chuyển tiếp cùng một đối số nhiều lần trong một lời gọi hàm:

template<typename C>
void foo(C&& obj)
{
    bar(std::forward<C>(obj), std::forward<C>(obj)); // Dangerous!
}

Cũng lưu ý rằng bạn thường không sử dụng giải pháp dựa trên mẫu trừ khi bạn có lý do chính đáng cho nó, vì nó làm cho mã của bạn khó đọc hơn. Thông thường, bạn nên tập trung vào sự rõ ràng và đơn giản .

Trên đây chỉ là những hướng dẫn đơn giản, nhưng phần lớn chúng sẽ hướng bạn đến những quyết định thiết kế tốt.


QUAN TÂM ĐĂNG KÝ CỦA BẠN:

Nếu tôi viết lại thành [...] thì sẽ có 2 lần di chuyển và không có bản sao.

Điều này LAF không đúng. Để bắt đầu, một tham chiếu rvalue không thể liên kết với một lvalue, vì vậy điều này sẽ chỉ biên dịch khi bạn đang chuyển một kiểu rvalue CreditCardcho hàm tạo của mình. Ví dụ:

// Here you are passing a temporary (OK! temporaries are rvalues)
Account acc("asdasd",345, CreditCard("12345",2,2015,1001));

CreditCard cc("12345",2,2015,1001);
// Here you are passing the result of std::move (OK! that's also an rvalue)
Account acc("asdasd",345, std::move(cc));

Nhưng nó sẽ không hoạt động nếu bạn cố gắng làm điều này:

CreditCard cc("12345",2,2015,1001);
Account acc("asdasd",345, cc); // ERROR! cc is an lvalue

cclà một giá trị và các tham chiếu giá trị không thể liên kết với các giá trị. Hơn nữa, khi ràng buộc một tham chiếu với một đối tượng, không có động thái nào được thực hiện : nó chỉ là một ràng buộc tham chiếu. Vì vậy, sẽ chỉ có một nước đi .


Vì vậy, dựa trên các hướng dẫn được cung cấp trong phần đầu tiên của câu trả lời này, nếu bạn quan tâm đến số lần di chuyển được tạo ra khi bạn lấy một CreditCardgiá trị theo giá trị, bạn có thể xác định hai quá tải hàm tạo, một lấy tham chiếu giá trị đến const( CreditCard const&) và một lấy một tham chiếu rvalue ( CreditCard&&).

Phân giải quá tải sẽ chọn cái trước khi chuyển một giá trị (trong trường hợp này, một bản sao sẽ được thực hiện) và cái sau khi truyền một giá trị (trong trường hợp này, một lần di chuyển sẽ được thực hiện).

Account(std::string number, float amount, CreditCard const& creditCard) 
: number(number), amount(amount), creditCard(creditCard) // copy here
{ }

Account(std::string number, float amount, CreditCard&& creditCard) 
: number(number), amount(amount), creditCard(std::move(creditCard)) // move here
{ }

Việc sử dụng của bạn std::forward<>thường được xem khi bạn muốn đạt được chuyển tiếp hoàn hảo . Trong trường hợp đó, phương thức khởi tạo của bạn thực sự sẽ là một mẫu phương thức khởi tạo và sẽ trông giống như sau

template<typename C>
Account(std::string number, float amount, C&& creditCard) 
: number(number), amount(amount), creditCard(std::forward<C>(creditCard)) { }

Theo một nghĩa nào đó, điều này kết hợp cả hai quá tải mà tôi đã hiển thị trước đây thành một hàm duy nhất: Csẽ được suy luận là CreditCard&trong trường hợp bạn đang chuyển một giá trị và do các quy tắc thu gọn tham chiếu, nó sẽ khiến hàm này được khởi tạo:

Account(std::string number, float amount, CreditCard& creditCard) : 
number(num), amount(amount), creditCard(std::forward<CreditCard&>(creditCard)) 
{ }

Điều này sẽ tạo ra một bản sao xây dựng của creditCard, như bạn mong muốn. Mặt khác, khi một giá trị được chuyển qua, Csẽ được suy ra là CreditCardvà thay vào đó, hàm này sẽ được khởi tạo:

Account(std::string number, float amount, CreditCard&& creditCard) : 
number(num), amount(amount), creditCard(std::forward<CreditCard>(creditCard)) 
{ }

Điều này sẽ gây ra một động thái-xây dựng của creditCard, đó là những gì bạn muốn (vì giá trị được thông qua là một rvalue, và điều đó có nghĩa là chúng ta được phép di chuyển từ nó).


Có sai không khi nói rằng đối với các kiểu tham số không phải nguyên thủy, bạn luôn sử dụng phiên bản mẫu có chuyển tiếp?
Jack Willson

1
@JackWillson: Tôi muốn nói rằng bạn chỉ nên sử dụng phiên bản mẫu khi bạn thực sự lo lắng về hiệu suất của các bước di chuyển. Xem câu hỏi này của tôi , câu hỏi có câu trả lời tốt, để biết thêm thông tin. Nói chung, nếu bạn không phải sao chép và chỉ cần quan sát, hãy tham khảo const. Nếu bạn cần sửa đổi đối tượng ban đầu, hãy tham chiếu đến không phải `const. Nếu bạn cần sao chép và di chuyển rẻ, hãy tính theo giá trị và sau đó di chuyển.
Andy Prowl

2
Tôi nghĩ rằng không cần phải di chuyển trong mẫu mã thứ ba. Bạn chỉ có thể sử dụng objthay vì tạo một bản sao cục bộ để chuyển vào, không?
juanchopanza

3
@AndyProwl: Bạn đã nhận được +1 giờ của tôi, nhưng đọc lại câu trả lời (xuất sắc) của bạn, tôi muốn chỉ ra hai điều: a) theo giá trị không phải lúc nào cũng tạo ra một bản sao (nếu nó không được di chuyển). Đối tượng có thể được xây dựng tại chỗ trên trang web của người gọi, đặc biệt là với RVO / NRVO, điều này thậm chí hoạt động thường xuyên hơn những gì người ta nghĩ ban đầu. b) Hãy chỉ ra rằng trong ví dụ cuối cùng, std::forwardcó thể chỉ được gọi một lần . Tôi đã thấy mọi người đưa nó vào các vòng lặp, v.v. và vì câu trả lời này sẽ được rất nhiều người mới bắt đầu nhìn thấy, nên IMHO phải có nhãn "Cảnh báo!" - để giúp họ tránh cái bẫy này.
Daniel Frey

1
@SteveJessop: Ngoài ra, còn có các vấn đề kỹ thuật (không nhất thiết là vấn đề) với các hàm chuyển tiếp ( đặc biệt là các hàm tạo lấy một đối số ), chủ yếu là chúng chấp nhận các đối số thuộc bất kỳ loại nào và có thể đánh bại std::is_constructible<>đặc điểm loại trừ khi chúng đúng SFINAE- bị hạn chế - điều này có thể không tầm thường đối với một số người.
Andy Prowl

11

Trước tiên, hãy để tôi sửa một số chi tiết. Khi bạn nói những điều sau:

sẽ có 2 lần di chuyển và không có bản sao.

Điều đó là sai. Liên kết với một tham chiếu giá trị không phải là một động thái. Chỉ có một nước đi.

Ngoài ra, vì CreditCardkhông phải là tham số mẫu, nên std::forward<CreditCard>(creditCard)chỉ là một cách nói dài dòng std::move(creditCard).

Hiện nay...

Nếu kiểu của bạn có những động thái "rẻ tiền", bạn có thể chỉ muốn làm cho cuộc sống của mình trở nên dễ dàng và coi mọi thứ theo giá trị và " std::movecùng".

Account(std::string number, float amount, CreditCard creditCard)
: number(std::move(number),
  amount(amount),
  creditCard(std::move(creditCard)) {}

Cách tiếp cận này sẽ mang lại cho bạn hai bước đi khi nó có thể chỉ mang lại một bước, nhưng nếu các bước đi rẻ, chúng có thể được chấp nhận.

Trong khi chúng ta đang quan tâm đến vấn đề "động thái giá rẻ" này, tôi nên nhắc bạn rằng điều đó std::stringthường được thực hiện với cái gọi là tối ưu hóa chuỗi nhỏ, vì vậy động thái của nó có thể không rẻ bằng sao chép một số con trỏ. Như thường lệ với các vấn đề tối ưu hóa, vấn đề có quan trọng hay không là điều cần hỏi trình biên dịch của bạn, không phải tôi.

Phải làm gì nếu bạn không muốn phải chịu thêm những động thái đó? Có thể chúng quá đắt, hoặc tệ hơn, có thể chúng thực sự không thể di chuyển được và bạn có thể phải trả thêm các bản sao.

Nếu chỉ có một tham số có vấn đề, bạn có thể cung cấp hai quá tải, với T const&T&&. Điều đó sẽ ràng buộc các tham chiếu mọi lúc cho đến khi khởi tạo thành viên thực sự, khi một bản sao hoặc di chuyển xảy ra.

Tuy nhiên, nếu bạn có nhiều hơn một tham số, điều này dẫn đến sự bùng nổ theo cấp số nhân về số lượng quá tải.

Đây là một vấn đề có thể được giải quyết với tính năng chuyển tiếp hoàn hảo. Điều đó có nghĩa là bạn viết một mẫu để thay thế và sử dụng std::forwardđể mang theo danh mục giá trị của các đối số đến đích cuối cùng của chúng với tư cách là thành viên.

template <typename TString, typename TCreditCard>
Account(TString&& number, float amount, TCreditCard&& creditCard)
: number(std::forward<TString>(number),
  amount(amount),
  creditCard(std::forward<TCreditCard>(creditCard)) {}

Có vấn đề với phiên bản mẫu: người dùng không thể viết Account("",0,{brace, initialisation})nữa.
ipc

@ipc à, đúng. Điều đó thực sự gây khó chịu và tôi không nghĩ rằng có một cách giải quyết dễ dàng có thể mở rộng.
R. Martinho Fernandes

6

Trước hết, std::stringlà một loại lớp khá lớn giống như std::vector. Nó chắc chắn không phải là nguyên thủy.

Nếu bạn đang lấy bất kỳ kiểu di chuyển lớn nào theo giá trị vào một hàm tạo, tôi sẽ std::moveđưa chúng vào thành viên:

CreditCard(std::string number, float amount, CreditCard creditCard)
  : number(std::move(number)), amount(amount), creditCard(std::move(creditCard))
{ }

Đây chính xác là cách tôi khuyên bạn nên triển khai hàm tạo. Nó khiến các thành viên numbercreditCardđược xây dựng di chuyển, chứ không phải là cấu trúc sao chép. Khi bạn sử dụng hàm tạo này, sẽ có một bản sao (hoặc di chuyển, nếu tạm thời) khi đối tượng được truyền vào phương thức khởi tạo và sau đó một lần di chuyển khi khởi tạo thành viên.

Bây giờ hãy xem xét hàm tạo này:

Account(std::string number, float amount, CreditCard& creditCard)
  : number(number), amount(amount), creditCard(creditCard)

Bạn nói đúng, điều này sẽ liên quan đến một bản sao của creditCard, vì nó được chuyển đến phương thức khởi tạo đầu tiên bằng tham chiếu. Nhưng bây giờ bạn không thể truyền constcác đối tượng cho hàm tạo (vì tham chiếu không phải const) và bạn không thể truyền các đối tượng tạm thời. Ví dụ: bạn không thể làm điều này:

Account account("something", 10.0f, CreditCard("12345",2,2015,1001));

Bây giờ chúng ta hãy xem xét:

Account(std::string number, float amount, CreditCard&& creditCard)
  : number(number), amount(amount), creditCard(std::forward<CreditCard>(creditCard))

Ở đây bạn đã cho thấy sự hiểu lầm về các tham chiếu rvalue và std::forward. Bạn chỉ thực sự nên sử dụng std::forwardkhi đối tượng mà bạn đang chuyển tiếp được khai báo như T&&đối với một số kiểu suy luận T . Đây CreditCardkhông phải là suy luận (tôi đang giả định), và do đó, std::forwardđang được sử dụng sai. Tra cứu tài liệu tham khảo phổ quát .


1

Tôi sử dụng một quy tắc khá đơn giản cho trường hợp chung: Sử dụng bản sao cho POD (int, bool, double, ...) và const & cho mọi thứ khác ...

Và muốn sao chép hay không, không được trả lời bởi chữ ký của phương pháp mà nhiều hơn bởi những gì bạn làm với các paramaters.

struct A {
  A(const std::string& aValue, const std::string& another) 
    : copiedValue(aValue), justARef(another) {}
  std::string copiedValue;
  const std::string& justARef; 
};

độ chính xác cho con trỏ: Tôi hầu như không bao giờ sử dụng chúng. Chỉ có lợi thế hơn & là chúng có thể bị trống hoặc được chỉ định lại.


2
"Tôi sử dụng một quy tắc khá đơn giản cho trường hợp chung: Sử dụng bản sao cho POD (int, bool, double, ...) và const & cho mọi thứ khác." Không. Chỉ cần.
Giày.

Có thể thêm, nếu bạn muốn sửa đổi giá trị bằng cách sử dụng & (không có hằng số). Nếu không, tôi không thấy ... đơn giản là đủ tốt. Nhưng nếu bạn nói như vậy ...
David Fleury

Đôi khi bạn muốn sửa đổi bằng các kiểu nguyên thủy tham chiếu, và đôi khi bạn cần tạo một bản sao của các đối tượng và đôi khi bạn phải di chuyển các đối tượng. Bạn không thể chỉ giảm mọi thứ thành POD> theo giá trị và UDT> theo tham chiếu.
Giày vào

Ok, đây chỉ là những gì tôi đã thêm sau. Có thể là một câu trả lời quá nhanh.
David Fleury

1

Đối với tôi, điều quan trọng nhất là không rõ ràng: truyền các tham số.

  • Nếu bạn muốn sửa đổi biến được truyền vào bên trong hàm / phương thức
    • bạn chuyển nó bằng cách tham khảo
    • bạn chuyển nó như một con trỏ (*)
  • Nếu bạn muốn đọc giá trị / biến được truyền vào bên trong hàm / phương thức
    • bạn chuyển nó bằng tham chiếu const
  • Nếu bạn muốn sửa đổi giá trị được truyền vào bên trong hàm / phương thức
    • bạn chuyển nó bình thường bằng cách sao chép đối tượng (**)

(*) con trỏ có thể tham chiếu đến bộ nhớ được cấp phát động, do đó, khi có thể, bạn nên thích tham chiếu hơn con trỏ ngay cả khi cuối cùng, tham chiếu thường được triển khai dưới dạng con trỏ.

(**) "normal" có nghĩa là hàm tạo sao chép (nếu bạn truyền một đối tượng cùng kiểu của tham số) hoặc bằng hàm tạo bình thường (nếu bạn truyền một kiểu tương thích cho lớp). myMethod(std::string)Ví dụ, khi bạn truyền một đối tượng , hàm tạo bản sao sẽ được sử dụng nếu một std::stringđược truyền cho nó, do đó bạn phải đảm bảo rằng một đối tượng tồn tại.

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.