Đối tượng bất biến trọn đời so với ngữ nghĩa di chuyển


13

Khi tôi học C ++ từ lâu, tôi đã nhấn mạnh với tôi rằng một phần của quan điểm của C ++ là giống như các vòng lặp có "vòng lặp bất biến", các lớp cũng có bất biến liên quan đến thời gian tồn tại của đối tượng - những điều đó phải là sự thật cho đến khi đối tượng còn sống. Những thứ nên được thiết lập bởi các nhà xây dựng, và được bảo tồn bằng các phương thức. Đóng gói / kiểm soát truy cập là có để giúp bạn thực thi các bất biến. RAII là một điều mà bạn có thể làm với ý tưởng này.

Kể từ C ++ 11, chúng tôi đã có ngữ nghĩa di chuyển. Đối với một lớp hỗ trợ di chuyển, di chuyển từ một đối tượng không chính thức kết thúc thời gian sống của nó - việc di chuyển được cho là để nó ở trạng thái "hợp lệ".

Trong việc thiết kế một lớp, có phải là một thực tiễn xấu nếu bạn thiết kế nó sao cho các bất biến của lớp chỉ được bảo toàn cho đến khi nó được di chuyển từ đó? Hoặc là ổn nếu nó sẽ cho phép bạn làm cho nó đi nhanh hơn.

Để làm cho nó cụ thể, giả sử tôi có một loại tài nguyên không thể sao chép nhưng có thể di chuyển như vậy:

class opaque {
  opaque(const opaque &) = delete;

public:
  opaque(opaque &&);

  ...

  void mysterious();
  void mysterious(int);
  void mysterious(std::vector<std::string>);
};

Và vì bất kỳ lý do gì, tôi cần phải tạo một trình bao bọc có thể sao chép cho đối tượng này, để nó có thể được sử dụng, có lẽ trong một số hệ thống điều phối hiện có.

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { o_->mysterious(); }
  void operator()(int i) { o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { o_->mysterious(v); }
};

Trong copyable_opaqueđối tượng này , một bất biến của lớp được thiết lập khi xây dựng là thành viên o_luôn trỏ đến một đối tượng hợp lệ, vì không có ctor mặc định và ctor duy nhất không phải là ctor sao chép đảm bảo những điều này. Tất cả các operator()phương pháp đều cho rằng bất biến này giữ và bảo tồn nó sau đó.

Tuy nhiên, nếu đối tượng được di chuyển từ đó, sau đó o_sẽ trỏ đến không có gì. Và sau thời điểm đó, việc gọi bất kỳ phương thức nào operator()sẽ gây ra sự cố UB / sự cố.

Nếu đối tượng không bao giờ được di chuyển từ đó, thì bất biến sẽ được bảo toàn ngay đến lệnh gọi dtor.

Giả sử rằng giả thuyết, tôi đã viết lớp này, và nhiều tháng sau, đồng nghiệp tưởng tượng của tôi đã trải qua UB bởi vì, trong một số chức năng phức tạp, trong đó có rất nhiều đối tượng bị xáo trộn vì một số lý do, anh ta đã chuyển từ một trong những điều này và sau đó được gọi là một trong những phương pháp của nó. Rõ ràng đó là lỗi của anh ấy vào cuối ngày, nhưng lớp học này có "được thiết kế kém?"

Suy nghĩ:

  1. Nó thường là dạng xấu trong C ++ để tạo các đối tượng zombie phát nổ nếu bạn chạm vào chúng.
    Nếu bạn không thể xây dựng một số đối tượng, không thể thiết lập các bất biến, sau đó ném ngoại lệ từ ctor. Nếu bạn không thể bảo toàn các bất biến trong một số phương thức, thì hãy báo hiệu lỗi bằng cách nào đó và quay lại. Điều này có nên khác đối với các đối tượng chuyển từ?

  2. Là đủ để chỉ tài liệu "sau khi đối tượng này đã được di chuyển từ, nó là bất hợp pháp (UB) để làm bất cứ điều gì với nó ngoài việc phá hủy nó" trong tiêu đề?

  3. Có phải tốt hơn để liên tục khẳng định rằng nó là hợp lệ trong mỗi cuộc gọi phương thức?

Thích như vậy:

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { assert(o_); o_->mysterious(); }
  void operator()(int i) { assert(o_); o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { assert(o_); o_->mysterious(v); }
};

Các xác nhận không cải thiện đáng kể hành vi và chúng gây ra sự chậm lại. Nếu dự án của bạn sử dụng lược đồ "xây dựng phát hành / gỡ lỗi xây dựng", thay vì luôn luôn chạy với các xác nhận, tôi đoán điều này hấp dẫn hơn, vì bạn không trả tiền cho séc trong bản dựng phát hành. Nếu bạn không thực sự có các bản dựng gỡ lỗi, thì điều này có vẻ không hấp dẫn lắm.

  1. Là tốt hơn để làm cho lớp có thể sao chép, nhưng không di chuyển?
    Điều này cũng có vẻ xấu và gây ra hiệu quả, nhưng nó giải quyết vấn đề "bất biến" một cách đơn giản.

Những gì bạn sẽ coi là "thực hành tốt nhất" có liên quan ở đây?



Câu trả lời:


20

Nó thường là dạng xấu trong C ++ để tạo các đối tượng zombie phát nổ nếu bạn chạm vào chúng.

Nhưng đó không phải là những gì bạn đang làm. Bạn đang tạo một "đối tượng zombie" sẽ phát nổ nếu bạn chạm nhầm vào nó . Điều này cuối cùng không khác gì bất kỳ điều kiện tiên quyết dựa trên nhà nước khác.

Hãy xem xét các chức năng sau:

void func(std::vector<int> &v)
{
  v[0] = 5;
}

Chức năng này có an toàn không? Không; người dùng có thể vượt qua một sản phẩm nào vector . Vì vậy, hàm có một điều kiện tiên quyết thực tế vcó ít nhất một phần tử trong đó. Nếu không, bạn sẽ nhận được UB khi bạn gọi func.

Vì vậy, chức năng này không "an toàn". Nhưng điều đó không có nghĩa là nó bị hỏng. Nó chỉ bị hỏng nếu mã sử dụng nó vi phạm điều kiện tiên quyết. Có thể funclà một hàm tĩnh được sử dụng như một trợ giúp trong việc thực hiện các chức năng khác. Bản địa hóa theo cách như vậy, không ai sẽ gọi nó theo cách vi phạm các điều kiện tiên quyết của nó.

Nhiều hàm, cho dù là phạm vi không gian tên hoặc thành viên lớp, sẽ có những kỳ vọng về trạng thái của giá trị mà chúng hoạt động. Nếu các điều kiện tiên quyết này không được đáp ứng, thì các chức năng sẽ thất bại, điển hình là với UB.

Thư viện chuẩn C ++ định nghĩa quy tắc "hợp lệ nhưng không xác định". Điều này nói rằng, trừ khi tiêu chuẩn nói khác, mọi đối tượng được chuyển từ sẽ hợp lệ (nó là đối tượng hợp pháp của loại đó), nhưng trạng thái cụ thể của đối tượng đó không được chỉ định. Có bao nhiêu yếu tố để di chuyển từ vectorcó? Nó không nói.

Đây có nghĩa là bạn không thể gọi bất kỳ chức năng trong đó có bất kỳ điều kiện tiên quyết. vector::operator[]có điều kiện tiên quyết là vectorcó ít nhất một yếu tố. Vì bạn không biết trạng thái của vector, bạn không thể gọi nó. Sẽ không tốt hơn gọi điện funcmà không cần xác minh trước rằng vectorkhông trống.

Nhưng điều này cũng có nghĩa là các chức năng không có điều kiện tiên quyết là tốt. Đây là mã C ++ 11 hoàn toàn hợp pháp:

vector<int> v1 = {1, 2, 3, 4, 5};
vector<int> v2{std::move(v1)};
v1.assign({6, 7, 8, 9, 10});

vector::assignkhông có điều kiện tiên quyết. Nó sẽ hoạt động với bất kỳ vectorđối tượng hợp lệ nào , ngay cả một đối tượng đã được di chuyển từ đó.

Vì vậy, bạn không tạo ra một đối tượng bị hỏng. Bạn đang tạo một đối tượng không rõ trạng thái.

Nếu bạn không thể xây dựng một số đối tượng, không thể thiết lập các bất biến, sau đó ném ngoại lệ từ ctor. Nếu bạn không thể bảo toàn các bất biến trong một số phương thức, thì hãy báo hiệu lỗi bằng cách nào đó và quay lại. Điều này có nên khác đối với các đối tượng chuyển từ?

Ném ngoại lệ từ một nhà xây dựng di chuyển thường được coi là ... thô lỗ. Nếu bạn di chuyển một đối tượng sở hữu bộ nhớ, thì bạn đang chuyển quyền sở hữu bộ nhớ đó. Và điều đó thường không liên quan đến bất cứ điều gì có thể ném.

Đáng buồn thay, chúng ta không thể thực thi điều này vì nhiều lý do . Chúng ta phải chấp nhận rằng ném-di chuyển là một khả năng.

Cũng cần lưu ý rằng bạn không phải tuân theo ngôn ngữ "hợp lệ nhưng chưa được xác định". Đó đơn giản là cách thư viện chuẩn C ++ nói rằng chuyển động cho các loại tiêu chuẩn hoạt động theo mặc định . Một số loại thư viện tiêu chuẩn có đảm bảo nghiêm ngặt hơn. Ví dụ, unique_ptrrất rõ ràng về trạng thái của một unique_ptrví dụ di chuyển : nó bằng nullptr.

Vì vậy, bạn có thể chọn để cung cấp một đảm bảo mạnh mẽ hơn nếu bạn muốn.

Chỉ cần nhớ: chuyển động là một tối ưu hóa hiệu suất , một điều thường được thực hiện trên các đối tượng sắp bị phá hủy. Xem xét mã này:

vector<int> func()
{
  vector<int> v;
  //fill up `v`.
  return v;
}

Điều này sẽ chuyển từ vgiá trị trả về (giả sử trình biên dịch không bỏ qua nó). Và không có cách nào để tham khảo vsau khi di chuyển đã hoàn thành. Vì vậy, bất cứ công việc bạn đã làm để đưa vvào trạng thái hữu ích là vô nghĩa.

Trong hầu hết các mã, khả năng sử dụng một đối tượng di chuyển từ đối tượng là thấp.

Là đủ để chỉ tài liệu "sau khi đối tượng này đã được di chuyển từ, nó là bất hợp pháp (UB) để làm bất cứ điều gì với nó ngoài việc phá hủy nó" trong tiêu đề?

Có phải tốt hơn để liên tục khẳng định rằng nó là hợp lệ trong mỗi cuộc gọi phương thức?

Toàn bộ quan điểm của việc có điều kiện tiên quyết là không kiểm tra những thứ như vậy. operator[]có một điều kiện tiên quyết là vectorcó một phần tử với chỉ số đã cho. Bạn nhận được UB nếu bạn cố gắng truy cập bên ngoài kích thước của vector. vector::at không có điều kiện tiên quyết như vậy; nó rõ ràng ném một ngoại lệ nếu vectornó không có giá trị như vậy.

Điều kiện tiên quyết tồn tại vì lý do hiệu suất. Chúng để bạn không phải kiểm tra những thứ mà người gọi có thể tự xác minh. Mọi cuộc gọi đến v[0]không phải kiểm tra nếu vtrống; chỉ người đầu tiên làm.

Là tốt hơn để làm cho lớp có thể sao chép, nhưng không di chuyển?

Trong thực tế, một lớp không bao giờ nên "có thể sao chép nhưng không di chuyển được". Nếu nó có thể được sao chép, thì nó sẽ có thể được di chuyển bằng cách gọi hàm tạo sao chép. Đây là hành vi tiêu chuẩn của C ++ 11 nếu bạn khai báo hàm tạo sao chép do người dùng định nghĩa nhưng không khai báo hàm tạo di chuyển. Và đó là hành vi bạn nên áp dụng nếu bạn không muốn thực hiện ngữ nghĩa di chuyển đặc biệt.

Di chuyển ngữ nghĩa tồn tại để giải quyết một vấn đề rất cụ thể: xử lý các đối tượng có tài nguyên lớn trong đó việc sao chép sẽ rất tốn kém hoặc vô nghĩa (ví dụ: xử lý tệp). Nếu đối tượng của bạn không đủ điều kiện, thì sao chép và di chuyển là như nhau đối với bạn.


5
Đẹp. +1. Tôi sẽ lưu ý rằng: "Toàn bộ quan điểm của việc có điều kiện tiên quyết là không kiểm tra những thứ như vậy." - Tôi không nghĩ rằng điều này giữ cho khẳng định. Các xác nhận là IMHO một công cụ tốt và hợp lệ để kiểm tra các điều kiện tiên quyết (ít nhất là hầu hết thời gian.)
Martin Ba

3
Sự nhầm lẫn sao chép / di chuyển có thể được làm rõ bằng cách nhận ra rằng một ctor di chuyển có thể rời khỏi đối tượng nguồn ở bất kỳ trạng thái nào, bao gồm giống hệt với đối tượng mới - điều đó có nghĩa là các kết quả có thể xảy ra là một thay thế của một ctor sao chép.
MSalters
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.