Tại sao nhiều hàm trả về cấu trúc trong C, thực sự trả về con trỏ cho cấu trúc?


49

Lợi thế của việc trả về một con trỏ cho một cấu trúc trái ngược với trả về toàn bộ cấu trúc trong returncâu lệnh của hàm là gì?

Tôi đang nói về các hàm như fopenvà các hàm cấp thấp khác nhưng có lẽ có các hàm cấp cao hơn cũng trả về các con trỏ cho các cấu trúc.

Tôi tin rằng đây là một sự lựa chọn thiết kế nhiều hơn là một câu hỏi lập trình và tôi tò mò muốn biết thêm về những lợi thế và bất lợi của hai phương pháp.

Một trong những lý do tôi nghĩ rằng đó sẽ là một lợi thế để trả về một con trỏ cho một cấu trúc là để có thể nói dễ dàng hơn nếu hàm bị lỗi bằng cách trả về NULLcon trỏ.

Trả lại một cấu trúc đầy đủNULL sẽ khó hơn tôi cho rằng hoặc kém hiệu quả hơn. Đây có phải là một lý do hợp lệ?


9
@ JohnR.Strohm Tôi đã thử nó và nó thực sự hoạt động. Một hàm có thể trả về một cấu trúc .... Vậy lý do không được thực hiện là gì?
yoyo_fun

27
Chuẩn hóa trước C không cho phép các cấu trúc được sao chép hoặc được truyền theo giá trị. Thư viện tiêu chuẩn C có nhiều tài liệu lưu giữ từ thời đại đó sẽ không được viết theo cách đó ngày hôm nay, ví dụ như phải mất đến C11, gets()chức năng hoàn toàn sai lệch mới bị xóa. Một số lập trình viên vẫn có ác cảm với việc sao chép các cấu trúc, thói quen cũ khó chết.
amon

26
FILE*thực sự là một tay cầm mờ đục. Mã người dùng không nên quan tâm cấu trúc bên trong của nó là gì.
CodeInChaos

3
Trả về theo tham chiếu chỉ là một mặc định hợp lý khi bạn có bộ sưu tập rác.
Idan Arye

6
@ JohnR.Strohm "rất cao cấp" trong hồ sơ của bạn dường như quay trở lại trước năm 1989 ;-) - khi ANSI C cho phép những gì K & R C không: Sao chép cấu trúc trong các bài tập, chuyển tham số và trả về giá trị. Cuốn sách gốc của K & R thực sự đã tuyên bố rõ ràng (tôi đang diễn giải): "bạn có thể thực hiện chính xác hai điều với một cấu trúc, lấy địa chỉ của nó & và truy cập một thành viên với .."
Peter - Phục hồi Monica

Câu trả lời:


61

Có một số lý do thực tế tại sao các hàm như fopencon trỏ trả về thay vì các thể structloại:

  1. Bạn muốn ẩn đại diện của structloại từ người dùng;
  2. Bạn đang phân bổ một đối tượng một cách linh hoạt;
  3. Bạn đang đề cập đến một thể hiện của một đối tượng thông qua nhiều tham chiếu;

Trong trường hợp các loại như FILE *, đó là vì bạn không muốn tiết lộ chi tiết về đại diện của loại đó cho người dùng - một FILE *đối tượng đóng vai trò là một tay cầm mờ đục và bạn chỉ chuyển giao xử lý đó cho các thói quen I / O khác nhau (và FILEthường là thực hiện như một structloại, nó không được).

Vì vậy, bạn có thể hiển thị một loại không đầy đủ struct trong một tiêu đề ở đâu đó:

typedef struct __some_internal_stream_implementation FILE;

Mặc dù bạn không thể khai báo một thể hiện của một loại không đầy đủ, bạn có thể khai báo một con trỏ tới nó. Vì vậy, tôi có thể tạo ra một FILE *và gán cho nó qua fopen, freopen, vv, nhưng tôi không thể trực tiếp thao tác các đối tượng mà nó trỏ tới.

Cũng có khả fopennăng là hàm đang phân bổ động một FILEđối tượng, sử dụng mallochoặc tương tự. Trong trường hợp đó, nó có ý nghĩa để trả về một con trỏ.

Cuối cùng, có thể bạn đang lưu trữ một loại trạng thái nào đó trong một structđối tượng và bạn cần làm cho trạng thái đó khả dụng ở một số nơi khác nhau. Nếu bạn trả về các thể hiện của structloại, các thể hiện đó sẽ là các đối tượng riêng biệt trong bộ nhớ với nhau và cuối cùng sẽ không đồng bộ. Bằng cách trả về một con trỏ tới một đối tượng, mọi người đều tham chiếu đến cùng một đối tượng.


31
Một lợi thế đặc biệt của việc sử dụng con trỏ làm kiểu mờ là cấu trúc có thể thay đổi giữa các phiên bản thư viện và bạn không cần phải biên dịch lại các cuộc gọi.
Barmar

6
@Barmar: Trên thực tế, ABI ổn định là các điểm bán hàng khổng lồ của C, và nó sẽ không được như ổn định mà không cần con trỏ đục.
Matthieu M.

37

Có hai cách "trả lại một cấu trúc." Bạn có thể trả về một bản sao của dữ liệu hoặc bạn có thể trả lại một tham chiếu (con trỏ) cho nó. Nói chung, nên trả lại (và chuyển xung quanh) một con trỏ, vì một vài lý do.

Đầu tiên, sao chép một cấu trúc tốn nhiều thời gian CPU hơn so với sao chép một con trỏ. Nếu đây là điều mà mã của bạn làm thường xuyên, nó có thể gây ra sự khác biệt hiệu suất đáng chú ý.

Thứ hai, cho dù bạn có sao chép một con trỏ xung quanh bao nhiêu lần đi chăng nữa thì nó vẫn chỉ vào cùng một cấu trúc trong bộ nhớ. Tất cả các sửa đổi cho nó sẽ được phản ánh trên cùng một cấu trúc. Nhưng nếu bạn sao chép chính cấu trúc đó và sau đó thực hiện sửa đổi, thay đổi chỉ hiển thị trên bản sao đó . Bất kỳ mã nào giữ một bản sao khác sẽ không thấy sự thay đổi. Đôi khi, rất hiếm khi, đây là những gì bạn muốn, nhưng hầu hết thời gian là không, và nó có thể gây ra lỗi nếu bạn làm sai.


54
Hạn chế của việc trả về bằng con trỏ: bây giờ bạn phải theo dõi quyền sở hữu đối tượng đó và có thể giải phóng nó. Ngoài ra, việc xác định con trỏ có thể tốn kém hơn một bản sao nhanh. Có rất nhiều biến ở đây, vì vậy sử dụng con trỏ không phải là tốt hơn.
amon

17
Ngoài ra, con trỏ ngày nay là 64 bit trên hầu hết các nền tảng máy tính để bàn và máy chủ. Tôi đã thấy nhiều hơn một vài cấu trúc trong sự nghiệp của mình sẽ phù hợp với 64 bit. Vì vậy, bạn không thể luôn nói rằng sao chép một con trỏ có chi phí thấp hơn so với sao chép một cấu trúc.
Solomon chậm

37
Đây chủ yếu là một câu trả lời tốt, nhưng đôi khi tôi không đồng ý về phần này , rất hiếm khi, đây là điều bạn muốn, nhưng hầu hết thời gian không - hoàn toàn ngược lại. Trả lại một con trỏ cho phép một số loại tác dụng phụ không mong muốn và một số cách khó chịu để làm sai quyền sở hữu của một con trỏ. Trong trường hợp thời gian CPU không quan trọng, tôi thích biến thể sao chép, nếu đó là một tùy chọn, nó sẽ ít bị lỗi hơn nhiều .
Doc Brown

6
Cần lưu ý rằng điều này thực sự chỉ áp dụng cho các API bên ngoài. Đối với các hàm bên trong, mọi trình biên dịch có khả năng biên của các thập kỷ trước sẽ viết lại một hàm trả về một cấu trúc lớn để lấy một con trỏ làm đối số bổ sung và xây dựng đối tượng trực tiếp trong đó. Các lập luận về bất biến so với đột biến đã được thực hiện đủ thường xuyên, nhưng tôi nghĩ tất cả chúng ta có thể đồng ý rằng tuyên bố rằng cấu trúc dữ liệu bất biến gần như không bao giờ là điều bạn muốn là không đúng.
Voo

6
Bạn cũng có thể đề cập đến các bức tường lửa tổng hợp như một chuyên gia cho con trỏ. Trong các chương trình lớn với các tiêu đề được chia sẻ rộng rãi, các loại không đầy đủ với các chức năng ngăn chặn sự cần thiết phải biên dịch lại mỗi khi thay đổi chi tiết triển khai. Hành vi biên dịch tốt hơn thực sự là một tác dụng phụ của việc đóng gói đạt được khi giao diện và cách thực hiện được tách ra. Trả lại (và chuyển, gán) theo giá trị cần thông tin thực hiện.
Peter - Phục hồi Monica

12

Ngoài các câu trả lời khác, đôi khi trả lại một giá trị nhỏ struct bằng giá trị là đáng giá. Ví dụ: người ta có thể trả về một cặp dữ liệu và một số mã lỗi (hoặc thành công) liên quan đến dữ liệu đó.

Để lấy một ví dụ, fopenchỉ trả về một dữ liệu (đã mở FILE*) và trong trường hợp có lỗi, đưa ra mã lỗi thông qua biến errnogiả toàn cầu. Nhưng có lẽ tốt hơn là trả về một structtrong hai thành viên: FILE*xử lý và mã lỗi (sẽ được đặt nếu xử lý tệp là NULL). Vì lý do lịch sử, đây không phải là trường hợp (và lỗi được báo cáo trên errnotoàn cầu, mà ngày nay là một vĩ mô).

Lưu ý rằng ngôn ngữ Go có một ký hiệu đẹp để trả về hai (hoặc một vài) giá trị.

Cũng lưu ý rằng trên Linux / x86-64, ABI và các quy ước gọi (xem trang x86-psABI ) chỉ định rằng một structtrong hai thành viên vô hướng (ví dụ: một con trỏ và một số nguyên hoặc hai con trỏ hoặc hai số nguyên) được trả về qua hai thanh ghi (và điều này rất hiệu quả và không đi qua bộ nhớ).

Vì vậy, trong mã C mới, việc trả về một C nhỏ structcó thể dễ đọc hơn, thân thiện với luồng hơn và hiệu quả hơn.


Trên thực tế các cấu trúc nhỏ được đóng gói vào rdx:rax. Vì vậy, struct foo { int a,b; };được trả lại đóng gói vào rax(ví dụ với shift / hoặc), và phải được giải nén với shift / Mov. Đây là một ví dụ về Godbolt . Nhưng x86 có thể sử dụng 32 bit thấp của thanh ghi 64 bit cho các hoạt động 32 bit mà không quan tâm đến các bit cao, do đó, nó luôn quá tệ, nhưng chắc chắn tệ hơn so với việc sử dụng 2 thanh ghi hầu hết thời gian cho các cấu trúc 2 thành viên.
Peter Cordes

Liên quan: bug.llvm.org/show_orms.cgi?id=34840 std::optional<int> trả về boolean ở nửa trên cùng rax, vì vậy bạn cần hằng số mặt nạ 64 bit để kiểm tra test. Hoặc bạn có thể sử dụng bt. Nhưng nó rất hấp dẫn đối với người gọi và callee so với việc sử dụng dl, trình biên dịch nào nên thực hiện cho các chức năng "riêng tư". Cũng liên quan: libstdc ++ std::optional<T>không thể sao chép một cách tầm thường ngay cả khi T là vậy, vì vậy nó luôn trả về qua con trỏ ẩn: stackoverflow.com/questions/46544019/ . (libc ++ 'có thể sao chép một cách tầm thường)
Peter Cordes

@PeterCordes: những thứ liên quan của bạn là C ++, không phải C
Basile Starynkevitch

Rất tiếc, phải. Vâng, điều tương tự sẽ áp dụng chính xác cho struct { int a; _Bool b; };C, nếu người gọi muốn kiểm tra boolean, bởi vì các cấu trúc C ++ có thể sao chép tầm thường sử dụng cùng ABI như C.
Peter Cordes

1
Ví dụ cổ điểndiv_t div()
chux - Phục hồi Monica

6

Bạn đang đi đúng hướng

Cả hai lý do bạn đề cập đều hợp lệ:

Một trong những lý do tôi nghĩ rằng đó sẽ là một lợi thế để trả về một con trỏ cho cấu trúc là để có thể nói dễ dàng hơn nếu hàm bị lỗi bằng cách trả về con trỏ NULL.

Trả lại một cấu trúc ĐẦY ĐỦ là NULL sẽ khó hơn tôi cho rằng hoặc kém hiệu quả hơn. Đây có phải là một lý do hợp lệ?

Nếu bạn có một kết cấu (ví dụ) ở đâu đó trong bộ nhớ và bạn muốn tham chiếu kết cấu đó ở một vài nơi trong chương trình của mình; Sẽ không khôn ngoan khi tạo một bản sao mỗi khi bạn muốn tham khảo nó. Thay vào đó, nếu bạn chỉ cần vượt qua một con trỏ để tham chiếu kết cấu, chương trình của bạn sẽ chạy nhanh hơn nhiều.

Lý do lớn nhất là phân bổ bộ nhớ động. Thông thường, khi một chương trình được biên dịch, bạn không chắc chắn chính xác mình cần bao nhiêu bộ nhớ cho các cấu trúc dữ liệu nhất định. Khi điều này xảy ra, lượng bộ nhớ bạn cần sử dụng sẽ được xác định khi chạy. Bạn có thể yêu cầu bộ nhớ bằng cách sử dụng 'malloc' và sau đó giải phóng nó khi bạn kết thúc sử dụng 'miễn phí'.

Một ví dụ điển hình của việc này là đọc từ một tệp được chỉ định bởi người dùng. Trong trường hợp này, bạn không biết tệp có thể lớn đến mức nào khi bạn biên dịch chương trình. Bạn chỉ có thể tìm ra bao nhiêu bộ nhớ bạn cần khi chương trình thực sự đang chạy.

Cả malloc và con trỏ trở lại miễn phí đến các vị trí trong bộ nhớ. Vì vậy, các hàm sử dụng phân bổ bộ nhớ động sẽ đưa con trỏ trở lại nơi chúng đã tạo cấu trúc của chúng trong bộ nhớ.

Ngoài ra, trong các bình luận tôi thấy rằng có một câu hỏi là liệu bạn có thể trả về một cấu trúc từ một hàm không. Bạn thực sự có thể làm điều này. Sau đây nên làm việc:

struct s1 {
   int integer;
};

struct s1 f(struct s1 input){
   struct s1 returnValue = xinput
   return returnValue;
}

int main(void){
   struct s1 a = { 42 };
   struct s1 b= f(a);

   return 0;
}

Làm thế nào có thể không biết một biến nhất định sẽ cần bao nhiêu bộ nhớ nếu bạn đã xác định kiểu cấu trúc?
yoyo_fun

9
@JenniferAnderson C có khái niệm về các loại không hoàn chỉnh: tên loại có thể được khai báo nhưng chưa được xác định, do đó kích thước của nó không có sẵn. Tôi không thể khai báo các biến của loại đó, nhưng có thể khai báo các con trỏ tới loại đó, vd struct incomplete* foo(void). Bằng cách đó, tôi có thể khai báo các hàm trong một tiêu đề, nhưng chỉ xác định các cấu trúc trong tệp C, do đó cho phép đóng gói.
amon

@amon Vì vậy, đây là cách khai báo các tiêu đề hàm (nguyên mẫu / chữ ký) trước khi khai báo cách chúng hoạt động thực sự được thực hiện trong C? Và có thể làm điều tương tự với các cấu trúc và công đoàn trong C
yoyo_fun

@JenniferAnderson bạn khai báo các nguyên mẫu hàm (hàm không có phần thân) trong các tệp tiêu đề và sau đó có thể gọi các hàm đó trong mã khác, mà không cần biết phần thân của hàm, vì trình biên dịch chỉ cần biết cách sắp xếp các đối số và cách chấp nhận giá trị trả về. Vào thời điểm bạn liên kết chương trình, bạn thực sự phải biết định nghĩa hàm (tức là với phần thân), nhưng bạn chỉ cần xử lý nó một lần. Nếu bạn sử dụng một loại không đơn giản, nó cũng cần biết cấu trúc của loại đó, nhưng các con trỏ thường có cùng kích thước và nó không quan trọng đối với việc sử dụng nguyên mẫu.
đơn giản hóa

6

Một cái gì đó giống như FILE*không thực sự là một con trỏ đến một cấu trúc liên quan đến mã máy khách, mà thay vào đó là một dạng định danh mờ được liên kết với một số thực thể khác như một tệp. Khi một chương trình gọi fopen, nó thường không quan tâm đến bất kỳ nội dung nào của cấu trúc được trả về - tất cả những gì nó sẽ quan tâm là các chức năng khác như freadsẽ làm bất cứ điều gì chúng cần làm với nó.

Nếu một thư viện chuẩn lưu trong FILE*thông tin về ví dụ như vị trí đọc hiện tại trong tệp đó, một cuộc gọi đến freadsẽ cần có thể cập nhật thông tin đó. Có freadđược một con trỏ để FILElàm cho dễ dàng. Nếu freadthay vào đó nhận được một FILE, nó sẽ không có cách nào cập nhật FILEđối tượng được giữ bởi người gọi.


3

Ẩn thông tin

Lợi thế của việc trả về một con trỏ cho một cấu trúc trái ngược với trả về toàn bộ cấu trúc trong câu lệnh return của hàm là gì?

Một trong những phổ biến nhất là ẩn thông tin . C không có khả năng tạo các trường structriêng tư, chứ chưa nói đến việc cung cấp các phương thức để truy cập chúng.

Vì vậy, nếu bạn muốn ngăn chặn mạnh mẽ các nhà phát triển có thể nhìn thấy và can thiệp vào nội dung của một điểm, như vậy FILE, cách duy nhất và duy nhất là ngăn họ tiếp xúc với định nghĩa của nó bằng cách coi con trỏ là mờ đục có kích thước điểm và định nghĩa là không biết với thế giới bên ngoài. Định nghĩa về FILEý chí sau đó chỉ hiển thị cho những người thực hiện các hoạt động yêu cầu định nghĩa của nó, như fopen, trong khi chỉ có khai báo cấu trúc sẽ hiển thị cho tiêu đề công khai.

Tương thích nhị phân

Ẩn định nghĩa cấu trúc cũng có thể giúp cung cấp phòng thở để duy trì khả năng tương thích nhị phân trong API dylib. Nó cho phép người triển khai thư viện thay đổi các trường trong cấu trúc mờ mà không phá vỡ tính tương thích nhị phân với những người sử dụng thư viện, vì bản chất của mã của họ chỉ cần biết họ có thể làm gì với cấu trúc, chứ không phải nó lớn như thế nào hoặc trường nào nó có.

Ví dụ, tôi thực sự có thể chạy một số chương trình cổ xưa được xây dựng trong thời đại Windows 95 ngày nay (không phải lúc nào cũng hoàn hảo, nhưng đáng ngạc nhiên là nhiều chương trình vẫn hoạt động). Rất có thể là một số mã cho các nhị phân cổ đó đã sử dụng các con trỏ mờ cho các cấu trúc có kích thước và nội dung đã thay đổi từ thời Windows 95. Tuy nhiên, các chương trình tiếp tục hoạt động trong các phiên bản mới của windows vì chúng không tiếp xúc với nội dung của các cấu trúc đó. Khi làm việc trên thư viện nơi khả năng tương thích nhị phân là quan trọng, những gì khách hàng không tiếp xúc thường được phép thay đổi mà không phá vỡ tính tương thích ngược.

Hiệu quả

Trả lại một cấu trúc đầy đủ là NULL sẽ khó hơn tôi cho rằng hoặc kém hiệu quả hơn. Đây có phải là một lý do hợp lệ?

Nó thường kém hiệu quả hơn khi giả sử loại có thể phù hợp trên thực tế và được phân bổ trên ngăn xếp trừ khi thường có bộ cấp phát bộ nhớ ít khái quát hơn được sử dụng phía sau hậu trường malloc, giống như bộ nhớ gộp của bộ cấp phát có kích thước thay đổi được phân bổ. Đây là một sự đánh đổi an toàn trong trường hợp này, rất có thể, cho phép các nhà phát triển thư viện duy trì các bất biến (đảm bảo khái niệm) liên quan đến FILE.

Đó không phải là một lý do hợp lệ ít nhất là từ quan điểm hiệu suất để fopentrả về một con trỏ vì lý do duy nhất nó trả về NULLlà do không mở được tệp. Điều đó sẽ tối ưu hóa một kịch bản đặc biệt để đổi lấy việc làm chậm tất cả các đường dẫn thực hiện trường hợp thông thường. Có thể có một lý do năng suất hợp lệ trong một số trường hợp để làm cho các thiết kế đơn giản hơn để làm cho chúng trả về các con trỏ để cho phép NULLđược trả về trong một số điều kiện hậu.

Đối với các hoạt động tập tin, chi phí hoạt động tương đối tầm thường so với chính các hoạt động tập tin và fclosekhông thể tránh khỏi hướng dẫn sử dụng . Vì vậy, không giống như chúng ta có thể tiết kiệm cho khách hàng những rắc rối trong việc giải phóng (đóng) tài nguyên bằng cách đưa ra định nghĩa FILEvà trả lại theo giá trị fopenhoặc hy vọng phần lớn hiệu suất được cung cấp do chi phí tương đối của các hoạt động tệp để tránh phân bổ heap .

Điểm nóng và sửa lỗi

Tuy nhiên, đối với các trường hợp khác, tôi đã lập hồ sơ rất nhiều mã C lãng phí trong các cơ sở mã di sản với các điểm nóng trong mallocbộ nhớ cache bắt buộc và không cần thiết do sử dụng thực hành này quá thường xuyên với các con trỏ mờ và phân bổ quá nhiều thứ không cần thiết trong đống, đôi khi trong các vòng lớn.

Một cách thực hành khác mà tôi sử dụng thay vào đó là để lộ các định nghĩa cấu trúc, ngay cả khi máy khách không có ý định giả mạo chúng, bằng cách sử dụng một tiêu chuẩn quy ước đặt tên để truyền đạt rằng không ai khác nên chạm vào các trường:

struct Foo
{
   /* priv_* indicates that you shouldn't tamper with these fields! */
   int priv_internal_field;
   int priv_other_one;
};

struct Foo foo_create(void);
void foo_destroy(struct Foo* foo);
void foo_something(struct Foo* foo);

Nếu có những lo ngại về khả năng tương thích nhị phân trong tương lai, thì tôi đã thấy nó đủ tốt để chỉ dự trữ một số không gian thừa cho các mục đích trong tương lai, như vậy:

struct Foo
{
   /* priv_* indicates that you shouldn't tamper with these fields! */
   int priv_internal_field;
   int priv_other_one;

   /* reserved for possible future uses (emergency backup plan).
     currently just set to null. */
   void* priv_reserved;
};

Không gian dành riêng đó hơi lãng phí nhưng có thể là một trình tiết kiệm cuộc sống nếu chúng ta thấy trong tương lai chúng ta cần thêm một số dữ liệu vào Foomà không phá vỡ các nhị phân sử dụng thư viện của chúng tôi.

Theo tôi, ẩn thông tin và khả năng tương thích nhị phân thường là lý do hợp lý duy nhất để chỉ cho phép phân bổ các cấu trúc heap bên cạnh các cấu trúc có độ dài thay đổi (sẽ luôn yêu cầu nó, hoặc ít nhất là hơi khó sử dụng nếu khách hàng phải phân bổ bộ nhớ trên ngăn xếp theo kiểu VLA để phân bổ VLS). Ngay cả các cấu trúc lớn thường rẻ hơn để trả về theo giá trị nếu điều đó có nghĩa là phần mềm hoạt động nhiều hơn với bộ nhớ nóng trên ngăn xếp. Và ngay cả khi chúng không rẻ hơn để trả về theo giá trị khi tạo, người ta có thể chỉ cần làm điều này:

int foo_create(struct Foo* foo);
...
/* In the client code: */
struct Foo foo;
if (foo_create(&foo))
{
    foo_something(&foo);
    foo_destroy(&foo);
}

... để khởi tạo Footừ ngăn xếp mà không có khả năng sao chép thừa. Hoặc khách hàng thậm chí có quyền tự do phân bổ Footrên heap nếu họ muốn vì một lý do nào đó.

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.