Чи є якісь мінуси для передачі структур за значенням у C, а не передачі вказівника?


157

Чи є якісь мінуси для передачі структур за значенням у C, а не передачі вказівника?

Якщо структура є великою, очевидно, є виконуючий аспект копіювання безлічі даних, але для меншої структури вона в основному повинна бути такою ж, як передача декількох значень функції.

Це може бути навіть цікавіше, коли вони використовуються як повернені значення. C має лише одиничні значення повернення від функцій, але вам часто потрібно кілька. Таким простим рішенням є скласти їх у структуру та повернути це.

Чи є причини для цього чи проти?

Оскільки може бути не очевидно для всіх, про що я тут говорю, я наведу простий приклад.

Якщо ви програмуєте на C, ви рано чи пізно почнете писати функції, які виглядають приблизно так:

void examine_data(const char *ptr, size_t len)
{
    ...
}

char *p = ...;
size_t l = ...;
examine_data(p, l);

Це не проблема. Єдине питання полягає в тому, що ви повинні погодитись зі своїм колегою, в якому параметри порядку повинні бути, щоб ви використовували однаковий режим у всіх функціях.

Але що відбувається, коли ви хочете повернути той самий вид інформації? Зазвичай ви отримуєте щось подібне:

char *get_data(size_t *len);
{
    ...
    *len = ...datalen...;
    return ...data...;
}
size_t len;
char *p = get_data(&len);

Це прекрасно працює, але набагато проблематичніше. Повернене значення - це повернене значення, за винятком того, що в цій реалізації воно не є. Немає способу сказати з вищесказаного, що функції get_data заборонено дивитися на те, на що вказує len. І ніщо не змушує компілятора перевірити, чи значення насправді повертається через цей покажчик. Тож у наступному місяці, коли хтось інший модифікує код, не розуміючи його належним чином (тому що він не прочитав документацію?), Він розбивається, не помічаючи когось, або він починає вибиватися випадковим чином.

Отже, рішення, яке я пропоную, - це проста структура

struct blob { char *ptr; size_t len; }

Приклади можна переписати так:

void examine_data(const struct blob data)
{
    ... use data.tr and data.len ...
}

struct blob = { .ptr = ..., .len = ... };
examine_data(blob);

struct blob get_data(void);
{
    ...
    return (struct blob){ .ptr = ...data..., .len = ...len... };
}
struct blob data = get_data();

З якоїсь причини я думаю, що більшість людей інстинктивно змусили exam_data взяти покажчик на структурну крапку, але я не розумію, чому. Він все одно отримує вказівник і ціле число, просто набагато зрозуміліше, що вони йдуть разом. І у випадку get_data неможливо зіпсувати так, як я описав раніше, оскільки немає вхідного значення для довжини, і повинна бути повернута довжина.


Бо чого це варто, void examine data(const struct blob)невірно.
Кріс Лутц

Дякуємо, змінив його, щоб включити ім'я змінної.
dkagedal

1
"Немає способу сказати з вищесказаного, що функції get_data не дозволяється дивитись на те, на що вказує len. І нічого, що змушує компілятор перевірити, чи дійсно значення повертається через цей покажчик." - це для мене взагалі не має сенсу (можливо, тому що ваш приклад недійсний код через два останніх рядки, що з’являються поза функцією); будь ласка, можете ви докладно?
Адам Шпіерс

2
Два рядки під функцією є для того, щоб проілюструвати, як називається функція. Підпис функції не натякає на те, що реалізація повинна писати лише покажчик. І компілятор не може знати, що він повинен перевірити, чи є значення записане в покажчик, тому механізм повернення значення може бути описаний лише в документації.
dkagedal

1
Основна причина, чому люди не роблять цього частіше на С - це історична. До C89 ви не могли передавати або повертати структури за значенням, тому всі системні інтерфейси, що передували C89 і логічно повинні робити це (як gettimeofday), використовують замість цього покажчики, і люди беруть це як приклад.
zwol

Відповіді:


202

Для невеликих конструкцій (наприклад, точки, прямої) передача за значенням цілком прийнятна. Але, окрім швидкості, є ще одна причина, чому ви повинні обережно пропускати / повертати великі структури за значенням: Складіть простір.

Багато програм програмування на С розроблено для вбудованих систем, де пам'ять переважає, а розмір стека може бути виміряний у КБ або навіть байтах ... Якщо ви передаєте або повертаєте структури за значенням, копії цих структур будуть розміщені на стек, потенційно спричиняючи ситуацію, що цей сайт названий на честь ...

Якщо я бачу додаток, який, здається, має надмірне використання стека, структури, передані за значенням, - це одна з речей, яку я шукаю першою.


2
"Якщо ви передаєте або повертаєте структури за значенням, копії цих структур будуть розміщені на стеці", я б назвав braindead будь-якою ланцюжком інструментів, яка це робить. Так, сумно, що так багато зроблять це, але це не те, що вимагає стандарт C. Звичайний компілятор оптимізує все це.
Відновіть Моніку

1
@KubaOber Ось чому це не робиться часто: stackoverflow.com/questions/552134/…
Roddy

1
Чи є остаточна лінія, яка відокремлює малу структуру від великої структури?
Джозі Томпсон

63

Одна з причин не робити цього, про що не згадувалося, це те, що це може спричинити проблему, коли важлива бінарна сумісність.

Залежно від використовуваного компілятора, структури можуть передаватися через стек або регістри залежно від варіантів / реалізації компілятора

Дивіться: http://gcc.gnu.org/onlinedocs/gcc/Code-Gen-Options.html

-fpcc-struct-return

-freg-struct-return

Якщо два компілятори не згодні, речі можуть підірватися. Потрібно сказати, що основні причини цього не пояснюються - це споживання стека та продуктивність.


4
Це була така відповідь, яку я шукав.
dkagedal

2
Щоправда, але ці параметри не стосуються прохідної вартості. вони стосуються повернення структур, що зовсім інша річ. Повернення речей за допомогою посилань - це звичайно вірний спосіб стріляти в обидві ноги. int &bar() { int f; int &j(f); return j;};
Родді

19

Щоб дійсно відповісти на це питання, потрібно заглибитися в землю складання:

(У наступному прикладі використовується gcc на x86_64. Будь-хто може додавати інші архітектури, такі як MSVC, ARM тощо).

Давайте маємо нашу прикладну програму:

// foo.c

typedef struct
{
    double x, y;
} point;

void give_two_doubles(double * x, double * y)
{
    *x = 1.0;
    *y = 2.0;
}

point give_point()
{
    point a = {1.0, 2.0};
    return a;
}

int main()
{
    return 0;
}

Скомпілюйте його з повними оптимізаціями

gcc -Wall -O3 foo.c -o foo

Подивіться на збірку:

objdump -d foo | vim -

Ось що ми отримуємо:

0000000000400480 <give_two_doubles>:
    400480: 48 ba 00 00 00 00 00    mov    $0x3ff0000000000000,%rdx
    400487: 00 f0 3f 
    40048a: 48 b8 00 00 00 00 00    mov    $0x4000000000000000,%rax
    400491: 00 00 40 
    400494: 48 89 17                mov    %rdx,(%rdi)
    400497: 48 89 06                mov    %rax,(%rsi)
    40049a: c3                      retq   
    40049b: 0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

00000000004004a0 <give_point>:
    4004a0: 66 0f 28 05 28 01 00    movapd 0x128(%rip),%xmm0
    4004a7: 00 
    4004a8: 66 0f 29 44 24 e8       movapd %xmm0,-0x18(%rsp)
    4004ae: f2 0f 10 05 12 01 00    movsd  0x112(%rip),%xmm0
    4004b5: 00 
    4004b6: f2 0f 10 4c 24 f0       movsd  -0x10(%rsp),%xmm1
    4004bc: c3                      retq   
    4004bd: 0f 1f 00                nopl   (%rax)

Виключаючи noplколодки, give_two_doubles()має 27 байт, тоді як give_point()має 29 байт. З іншого боку, give_point()дає одну меншу інструкцію, ніжgive_two_doubles()

Що цікаво, ми помічаємо, що компілятор зміг оптимізуватись movдо більш швидких варіантів SSE2 movapdта movsd. Крім того, give_two_doubles()насправді переміщуються дані з пам’яті і з неї, що робить все повільним.

Мабуть, велика частина цього може не застосовуватися у вбудованих середовищах (саме там ігрове поле для C є більшістю часу в наші дні). Я не майстер збірки, тому будь-які коментарі будуть вітатися!


6
Підрахунок кількості інструкцій не все так цікаво, якщо ви не можете показати величезну різницю або порахувати більш цікаві аспекти, такі як число важко передбачуваних стрибків і т.д. .
dkagedal

6
@dkagedal: Правда. Зрештою, я думаю, що моя власна відповідь була написана дуже погано. Хоча я не дуже зосередився на кількості інструкцій (не знаю, що на вас справило таке враження: P), насправді слід зазначити, що передача структури за значенням є переважнішою для передачі посилання для малих типів. У будь-якому випадку перевагу передавати за значенням, тому що це простіше (жодне життя не жонглює, не потрібно турбуватися про те, щоб хтось міняв ваші дані чи constвесь час), і я виявив, що при копіюванні прохідної вартості не так багато штрафу за ефективність (якщо не виграють) всупереч тому, що багато хто може вірити.
kizzx2

15

Простим рішенням буде повернути код помилки як повернене значення, а все інше як параметр у функції.
Цей параметр може бути структурою курсу, але не бачити якоїсь особливої ​​переваги, передаючи це за значенням, тільки що надіслав покажчик.
Передача структури за значенням небезпечна, вам потрібно бути дуже обережним, що ви передаєте, пам’ятайте, що в C не існує конструктора копіювання, якщо один із параметрів структури є вказівником, значення вказівника буде скопійовано, це може бути дуже заплутано і важко підтримувати.

Просто для завершення відповіді (повний кредит для Родді ) використання стека - це ще одна причина не передати структуру за значенням, повірте мені, налагодження переповнення стека - це справжня PITA.

Відтворити до коментаря:

Передача структури за вказівником означає, що деяка сутність має право власності на цей об’єкт і має повне знання про те, що і коли слід випустити. Передаючи структуру за значенням, створюють приховані посилання на внутрішні дані структури (вказівники на іншу структуру тощо). Це важко підтримувати (можливо, але чому?).


6
Але передача вказівника не є більш "небезпечною" лише тому, що ви ставите її в структуру, тому я не купую її.
dkagedal

Відмінний момент копіювання структури, яка містить вказівник. Цей пункт може бути не дуже очевидним. Для тих, хто не знає, про що йдеться, зробіть пошук по глибокій копії та дрібній копії.
zooropa

1
Одне з умов функцій C полягає в тому, щоб вихідні параметри були перелічені першими перед вхідними параметрами, наприклад, int func (char * out, char * in);
zooropa

Ви маєте на увазі, як, наприклад, наприклад, getaddrinfo () ставить вихідний параметр останнім? :-) Існує тисяча конвенцій, і ви можете вибрати, що хочете.
dkagedal

10

Одне, про що люди тут забули згадати (або я це не помічав) - це те, що у конструкцій зазвичай є підкладка!

struct {
  short a;
  char b;
  short c;
  char d;
}

Кожен знак - 1 байт, кожен короткий - 2 байти. Наскільки велика структура? Ні, це не 6 байт. Принаймні, не на будь-яких більш часто використовуваних системах. У більшості систем це буде 8. Проблема полягає в тому, що вирівнювання не є постійним, воно залежить від системи, тому одна і та ж структура матиме різні вирівнювання та різні розміри для різних систем.

Мало того, що прокладка додатково з’їсть ваш стек, це ще й додасть непевності того, що ви не зможете заздалегідь передбачити прокладку, якщо ви не знаєте, як працює ваша системна колодка, а потім перегляньте кожну структуру, яку ви маєте у своєму додатку, та обчисліть розмір. для нього. Проходження вказівника займає передбачувану кількість місця - невизначеності немає. Розмір вказівника відомий системі, він завжди рівний, незалежно від того, як виглядає структура, а розміри вказівника завжди вибираються таким чином, щоб вони вирівнювалися і не потребували прокладки.


2
Так, але прокладка існує без залежності від передачі структури за значенням або посиланням.
Ілля

2
@dkagedal: Яку частину "різних розмірів у різних системах" ти не зрозумів? Просто тому, що це так у вашій системі, ви припускаєте, що він повинен бути однаковим для будь-якої іншої - саме тому ви не повинні проходити за значенням. Змінено зразок, тому він не працює і у вашій системі.
Мецькі

2
Я думаю, що коментарі Мецкі щодо прокладки структури є актуальними особливо для вбудованих систем, де розмір стека може бути проблемою.
zooropa

1
Я думаю, що зворотний бік аргументу полягає в тому, що якщо ваша структура є простою структурою (містить пару примітивних типів), передача за значенням дозволить компілятору перемикати її за допомогою регістрів - тоді як якщо ви використовуєте покажчики, все закінчується в пам'ять, яка повільніше. Це стає досить низьким рівнем і в значній мірі залежить від вашої цільової архітектури, якщо будь-який з цих ласощів має значення.
kizzx2

1
Якщо ваша структура невелика або ваш процесор має безліч регістрів (а процесори Intel не мають), дані опиняються на стеку, і це також пам'ять і така ж швидка / повільна, як і будь-яка інша пам'ять. Вказівник з іншого боку завжди малий і просто вказівник, і сам вказівник завжди завжди потрапляє в реєстр, коли використовується частіше.
Mecki

9

Я думаю, що ваше запитання досить добре підсумувало речі.

Ще однією перевагою передачі структур за значенням є те, що право власності на пам'ять явне. Не дивно, чи структура є з купи і хто несе відповідальність за її звільнення.


9

Я б сказав, що передача (не надто великої) структури за значенням як в якості параметрів, так і як зворотних значень - цілком законна методика. Звичайно, слід подбати про те, щоб структура була або типом POD, або семантика копії була чітко визначена.

Оновлення: Вибачте, у мене була надійна шапка на C ++. Я пригадую той час, коли повертати структуру з функції не було законно в C, але це, мабуть, змінилося відтоді. Я б все-таки сказав, що це дійсно до тих пір, поки всі компілятори, які ви очікуєте використовувати, підтримують цю практику.


Зауважте, що моє запитання стосувалось C, а не C ++.
dkagedal

Дійсно повернути структуру з функції просто не корисно :)
Ілля

1
Мені подобається пропозиція Іллі використовувати повернення як код помилки та параметри для повернення даних з функції.
zooropa

8

Ось щось ніхто не згадував:

void examine_data(const char *c, size_t l)
{
    c[0] = 'l'; // compiler error
}

void examine_data(const struct blob blob)
{
    blob.ptr[0] = 'l'; // perfectly legal, quite likely to blow up at runtime
}

Члени а const structє const, але якщо цей член є вказівником (як char *), він стає char *constшвидше тим, що const char *ми справді хочемо. Звичайно, ми могли б припустити, щоconst це документація про наміри, і кожен, хто порушує це, пише поганий код (який вони є), але це недостатньо добре для деяких (особливо тих, хто щойно витратив чотири години на пошук причини крах).

Альтернативою може бути створення struct const_blob { const char *c; size_t l }та використання цього, але це досить безладно - це потрапляє в ту саму проблему схеми іменування, що і у мене з typedefing покажчиками. Таким чином, більшість людей дотримуються лише двох параметрів (або, більш імовірно, для цього випадку, використовуючи бібліотеку рядків).


Так, це абсолютно законно, а також те, що ви хочете робити іноді. Але я погоджуюсь, що це обмеження структурного рішення, що ви не можете зробити ті покажчики, які вони вказують на те, щоб const.
dkagedal

Погана проблема з struct const_blobрішенням полягає в тому, що навіть якщо const_blobє члени, які відрізняються blobлише від "непрямої конкурентоспроможності", типи struct blob*до struct const_blob*аліменту вважатимуться виразними для цілей суворого правила дозволу. Отже, якщо код кидає blob*до const_blob*, будь-яка подальша записи до базової структурі з використанням одного типу буде мовчки недійсною все існуючих покажчики іншого типу, таким чином, що будь-яке використання буде посилатися на невизначений поведінка (яке , як правило , може бути нешкідливим, але може бути смертельною) .
supercat

5

Сторінка 150 Підручника зі складання ПК на веб-сайті http://www.drpaulcarter.com/pcasm/ має чітке пояснення про те, як C дозволяє функції повертати структуру:

C також дозволяє використовувати тип структури як повернене значення функції. Очевидно, що структура не може бути повернута в реєстр EAX. Різні компілятори вирішують цю ситуацію по-різному. Загальне рішення, яке використовують компілятори, - це внутрішньо переписати функцію як ту, яка приймає вказівник структури як параметр. Вказівник використовується для введення повернутого значення в структуру, визначену поза тим, що називається.

Я використовую такий код C для перевірки вищезазначеного твердження:

struct person {
    int no;
    int age;
};

struct person create() {
    struct person jingguo = { .no = 1, .age = 2};
    return jingguo;
}

int main(int argc, const char *argv[]) {
    struct person result;
    result = create();
    return 0;
}

Використовуйте "gcc -S" для створення збірки для цього фрагмента коду С:

    .file   "foo.c"
    .text
.globl create
    .type   create, @function
create:
    pushl   %ebp
    movl    %esp, %ebp
    subl    $16, %esp
    movl    8(%ebp), %ecx
    movl    $1, -8(%ebp)
    movl    $2, -4(%ebp)
    movl    -8(%ebp), %eax
    movl    -4(%ebp), %edx
    movl    %eax, (%ecx)
    movl    %edx, 4(%ecx)
    movl    %ecx, %eax
    leave
    ret $4
    .size   create, .-create
.globl main
    .type   main, @function
main:
    pushl   %ebp
    movl    %esp, %ebp
    subl    $20, %esp
    leal    -8(%ebp), %eax
    movl    %eax, (%esp)
    call    create
    subl    $4, %esp
    movl    $0, %eax
    leave
    ret
    .size   main, .-main
    .ident  "GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3"
    .section    .note.GNU-stack,"",@progbits

Стек перед викликом створюють:

        +---------------------------+
ebp     | saved ebp                 |
        +---------------------------+
ebp-4   | age part of struct person | 
        +---------------------------+
ebp-8   | no part of struct person  |
        +---------------------------+        
ebp-12  |                           |
        +---------------------------+
ebp-16  |                           |
        +---------------------------+
ebp-20  | ebp-8 (address)           |
        +---------------------------+

Стек відразу після виклику create:

        +---------------------------+
        | ebp-8 (address)           |
        +---------------------------+
        | return address            |
        +---------------------------+
ebp,esp | saved ebp                 |
        +---------------------------+

2
Тут є дві проблеми. Найбільш очевидним є те, що це зовсім не описує "як C дозволяє функції повертати структуру". Це лише описує, як це можна зробити на 32-розрядному пристрої x86, що, як правило, є однією з найбільш обмежених архітектур, коли ви дивитеся на кількість регістрів тощо. Друга проблема полягає в тому, що компілятори C генерують код для повернення значень продиктовано ABI (за винятком неекспортованих або вбудованих функцій). І, до речі, вбудовані функції - це, мабуть, одне з місць, де повертаючі структури є найбільш корисними.
dkagedal

Дякуємо за виправлення. Для отримання повної детальної інформації про виклик, en.wikipedia.org/wiki/Calling_convention є хорошою посиланням.
Jingguo Yao

@dkagedal: Важливим є не лише те, що x86 трапляється робити так, а в тому, що існує "універсальний" підхід (тобто цей), який дозволить компіляторам для будь-якої платформи підтримувати повернення будь-якого типу структури, яка не є " t настільки величезний, що підірвати стек. Хоча компілятори для багатьох платформ використовуватимуть інші більш ефективні засоби для обробки деяких повернених значень типу структури, немає необхідності в мові обмежувати типи повернення структури тими, якими платформа може обробляти оптимально.
supercat

0

Я просто хочу зазначити одну перевагу передачі ваших структур за значенням, що оптимізаційний компілятор може краще оптимізувати ваш код.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.