Инициализация составного объекта

Это довольно широкий вопрос, на который, похоже, нет единственно верного ответа.

Я был озадачен инициализацией составных объектов в течение достаточно долгого времени. Меня формально учили поставлять геттеры и сеттеры для всех данных членов и отдавать предпочтение необработанным указателям на объекты вместо автоматических объектов - это, кажется, контрастирует с тем, что советуют многие люди в Переполнении стека (такие как этот популярный пост).

Как же тогда мне начинать инициализацию объектно-составных объектов?

Это способ, которым я попытался бы инициализировать, используя то, что я изучил в школе:

class SmallObject1 {
public:
    SmallObject1() {};
};

class SmallObject2 {
    public:
        SmallObject2() {};
};

class BigObject {
    private:
        SmallObject1 *obj1;
        SmallObject2 *obj2;
        int field1;
        int field2;
    public:
        BigObject() {}
        BigObject(SmallObject1* obj1, SmallObject2* obj2, int field1, int field2) {
        // Assign values as you would expect
        }
        ~BigObject() {
            delete obj1;
            delete obj2;
        }
    // Apply getters and setters for ALL members here
};

int main() {
    // Create data for BigObject object
    SmallObject1 *obj1 = new SmallObject1();
    SmallObject2 *obj2 = new SmallObject2();
    int field1 = 1;
    int field2 = 2;

    // Using setters
    BigObject *bobj1 = new BigObject();
    // Set obj1, obj2, field1, field2 using setters

    // Using overloaded contructor
    BigObject *bobj2 = new BigObject(obj1, obj2, field1, field2);

    return 0;
}

Этот дизайн привлекателен, потому что он читабелен (для меня). Дело в том, что BigObject имеет указатели на свои объекты-члены, что позволяет инициализировать obj1 а также obj2 после инициализации. Тем не менее, динамическая память может сделать программу более сложной и запутанной в будущем, таким образом созревая для утечек памяти. Кроме того, использование методов получения и установки создает помехи в классе и может также сделать данные членов слишком простыми для доступа и изменения.

Это на самом деле плохая практика? Я часто нахожу моменты, когда мне нужно инициализировать объект-член отдельно от его владельца, что делает автоматические объекты непривлекательными. Кроме того, я рассмотрел возможность создания более крупных объектов своими собственными объектами-членами. Кажется, это имеет больше смысла с точки зрения безопасности, но меньше смысла с точки зрения ответственности за объект.

4 ответа

Решение

Меня формально учили поставлять геттеры и сеттеры для всех данных-членов и отдавать предпочтение необработанным указателям на объекты вместо автоматических объектов.

Лично у меня нет проблем с установщиками и получателями для всех членов данных. Хорошая практика - иметь и может спасти много горя, особенно если вы решаетесь на темы. На самом деле, многие инструменты UML автоматически генерируют их для вас. Вам просто нужно знать, что вернуть. В этом конкретном примере не возвращайте необработанный указатель на SmallObject1 *, Вернуть SmallObject1 * const вместо.

Вторая часть о

сырые указатели

сделано в образовательных целях.


Ваш главный вопрос: как вы структурируете хранилище объектов, зависит от более крупного дизайна. Является BigObject единственный класс, который когда-либо будет использовать SmallObject"S? Тогда я бы положил их полностью внутри BigObject как частные члены и делать все управление памятью там. Если SmallObjectделятся между различными объектами, и не обязательно BigObject класс, тогда я бы сделал то, что ты сделал. Тем не менее, я буду хранить ссылки или указатели, чтобы соответствовать им, а не удалять их в BigObject деструктор класса - BigObject не размещать их, поэтому не следует удалять.

Рассмотрим следующий код:

class SmallObj {
public:
  int i_;
  double j_;
  SmallObj(int i, double j) : i_(i), j_(j) {}
};

class A {
  SmallObj so_;
  int x_;
public:
  A(SmallObj so, int x) : so_(so), x_(x) {}
  int something();
  int sox() const { return so_.i_; }
};

class B {
  SmallObj* so_;
  int x_;
public:
  B(SmallObj* so, int x) : so_(so), x_(x) {}
  ~B() { delete so_; }
  int something();
  int sox() const { return so_->i_; }
};

int a1() {
  A mya(SmallObj(1, 42.), -1.);
  mya.something();
  return mya.sox();
}

int a2() {
  SmallObj so(1, 42.);
  A mya(so, -1.);
  mya.something();
  return mya.sox();
}

int b() {
  SmallObj* so = new SmallObj(1, 42.);
  B myb(so, -1.);
  myb.something();
  return myb.sox();
}

Недостатки подхода "А":

  • наше конкретное использование SmallObject делает нас зависимыми от его определения: мы не можем просто объявить его,
  • наш пример SmallObject является уникальным для нашего экземпляра (не разделяется),

Недостатки подхода "B" несколько:

  • нам нужно заключить договор о праве собственности и информировать об этом пользователя,
  • динамическое распределение памяти должно выполняться перед каждым B создано,
  • для доступа к членам этого жизненно важного объекта требуется косвенное
  • мы должны проверить нулевые указатели, если мы хотим поддерживать ваш конструктор по умолчанию,
  • уничтожение требует дальнейшего динамического вызова памяти,

Одним из аргументов против использования автоматических объектов является стоимость передачи их по значению.

Это сомнительно: во многих случаях тривиальных автоматических объектов компилятор может оптимизировать эту ситуацию и инициализировать подобъект в строке. Если конструктор тривиален, он может даже сделать все за одну инициализацию стека.

Вот GCC -O3 реализация a1()

_Z2a1v:
.LFB11:
  .cfi_startproc
  .cfi_personality 0x3,__gxx_personality_v0
  subq  $40, %rsp      ; <<
  .cfi_def_cfa_offset 48
  movabsq $4631107791820423168, %rsi  ; <<
  movq  %rsp, %rdi     ; <<
  movq  %rsi, 8(%rsp)  ; <<
  movl  $1, (%rsp)     ; <<
  movl  $-1, 16(%rsp)  ; <<
  call  _ZN1A9somethingEv
  movl  (%rsp), %eax
  addq  $40, %rsp
  .cfi_def_cfa_offset 8
  ret
  .cfi_endproc

Выделенный (; << Строки) - это компилятор, выполняющий конструкцию A на месте и его подобъекта SmallObj за один раз.

И a2() оптимизирует очень похоже:

_Z2a2v:
.LFB12:
  .cfi_startproc
  .cfi_personality 0x3,__gxx_personality_v0
  subq  $40, %rsp
  .cfi_def_cfa_offset 48
  movabsq $4631107791820423168, %rcx
  movq  %rsp, %rdi
  movq  %rcx, 8(%rsp)
  movl  $1, (%rsp)
  movl  $-1, 16(%rsp)
  call  _ZN1A9somethingEv
  movl  (%rsp), %eax
  addq  $40, %rsp
  .cfi_def_cfa_offset 8
  ret
  .cfi_endproc

И там есть б ():

_Z1bv:
.LFB16:
        .cfi_startproc
        .cfi_personality 0x3,__gxx_personality_v0
        .cfi_lsda 0x3,.LLSDA16
        pushq   %rbx
        .cfi_def_cfa_offset 16
        .cfi_offset 3, -16
        movl    $16, %edi
        subq    $16, %rsp
        .cfi_def_cfa_offset 32
.LEHB0:
        call    _Znwm
.LEHE0:
        movabsq $4631107791820423168, %rdx
        movl    $1, (%rax)
        movq    %rsp, %rdi
        movq    %rdx, 8(%rax)
        movq    %rax, (%rsp)
        movl    $-1, 8(%rsp)
.LEHB1:
        call    _ZN1B9somethingEv
.LEHE1:
        movq    (%rsp), %rdi
        movl    (%rdi), %ebx
        call    _ZdlPv
        addq    $16, %rsp
        .cfi_remember_state
        .cfi_def_cfa_offset 16
        movl    %ebx, %eax
        popq    %rbx
        .cfi_def_cfa_offset 8
        ret
.L6:
        .cfi_restore_state
.L3:
        movq    (%rsp), %rdi
        movq    %rax, %rbx
        call    _ZdlPv
        movq    %rbx, %rdi
.LEHB2:
        call    _Unwind_Resume
.LEHE2:
        .cfi_endproc

Понятно, что в этом случае мы заплатили высокую цену за переход по указателю вместо значения.

Теперь давайте рассмотрим следующий фрагмент кода:

class A {
    SmallObj* so_;
public:
    A(SmallObj* so);
    ~A();
};

class B {
    Database* db_;
public:
    B(Database* db);
    ~B();
};

Исходя из приведенного выше кода, что вы ожидаете от владения "SmallObj" в конструкторе A? А что вы ожидаете от владения "БД" в Б? Намереваетесь ли вы создать уникальное соединение с базой данных для каждого B, который вы создаете?

Чтобы еще больше ответить на ваш вопрос о предпочтении необработанных указателей, нам нужно взглянуть не дальше, чем стандарт C++ 2011 года, в котором были представлены концепции std::unique_ptr а также std::shared_ptr чтобы помочь разрешить неоднозначность владения, которая существовала со времен Cs strdup() (возвращает указатель на копию строки, не забудьте освободить).

Перед комитетом по стандартизации есть предложение ввести observer_ptr в C++17, который не является оберткой вокруг необработанного указателя.

Использование их с вашим предпочтительным подходом вводит много котла:

auto so = std::make_unique<SmallObject>(1, 42.);
A a(std::move(so), -1);

Мы знаем здесь a имеет право собственности на so экземпляр мы распределили, так как мы явно предоставляем ему право собственности через std::move, Но все это явно стоит символов. Контраст с:

A a(SmallObject(1, 42.), -1);

или же

SmallObject so(1, 4.2);
A a(so, -1);

Таким образом, я думаю, что в целом очень мало аргументов в пользу использования сырых указателей для небольших объектов для композиции. Вы должны просмотреть свой материал, который приведет вас к выводу, поскольку, вероятно, вы упустили из виду или неправильно поняли факторы в рекомендации о том, когда использовать необработанные указатели.

Другие описали причины оптимизации, я сейчас рассматриваю это с точки зрения типа / функциональности. По словам Страуструпа, "каждый конструктор должен установить инвариант класса". Какой ваш класс инвариант здесь? Важно знать (и определять!), Иначе вы будете загрязнять свои функции-члены ifПроверка правильности операции - это не намного лучше, чем отсутствие типов вообще. В 90-х у нас были подобные классы, но в настоящее время мы действительно придерживаемся инвариантных определений и хотим, чтобы объекты постоянно находились в допустимом состоянии. (Функциональное программирование идет дальше и пытается извлечь переменное состояние из объектов, чтобы объекты могли быть постоянными.)

  • Если ваш класс действителен, если у вас есть эти подобъекты, тогда имейте их как члены, точка.
  • Если вы хотите поделиться SmallObjects среди BigObjects, тогда вам нужны указатели.
  • Если допустимо не иметь данного SmallObject, но вам не нужно делиться, вы можете рассмотреть std::optional<SmallObject> члены. Необязательный обычно выделяет локально (по сравнению с кучей), таким образом, вы можете извлечь выгоду из локальности кэша.
  • Если вам трудно создать такой объект, например, слишком много параметров конструктора, тогда у вас есть две ортогональные проблемы: конструирование и члены класса. Решите строительные проблемы, введя класс построителя (шаблон Строителя). Обычно выполнимое решение состоит в том, чтобы все параметры всех конструкторов были необязательными.

Обратите внимание, что многие из нас, кто предпочитает функциональный стиль, считают построитель антишаблоном и используют его только для десериализации, если вообще используют. Причина в том, что очень сложно рассуждать о сборщике (что получится, получится ли, какой конструктор получит вызовы). Если у вас есть два целых, это всего лишь два целых. Ваша лучшая ставка обычно состоит в том, чтобы просто хранить их в отдельных переменных, тогда компилятор должен выполнить все виды оптимизации. Я не удивлюсь, если кусочки чудесным образом просто упадут на куски, а ваши целые будут построены "на месте", поэтому позже копия не требуется.

OTOH, если вы обнаружите, что одни и те же параметры "ограничены" (получают их значение) во многих местах заранее, чем другие, то вы можете ввести для них тип. В этом случае ваши две целые будут типом (предпочтительно структурой). Вы можете решить, хотите ли вы сделать его базовым классом BigObject, член или просто отдельный класс (вам придется выбрать третий, если у вас несколько порядков связывания) - в любом случае ваш конструктор теперь будет принимать новый класс вместо двух целых. Вы могли бы даже рассмотреть вопрос об устаревании вашего другого конструктора (тот, который принимает два целых числа) как 1. новый объект может быть легко сконструирован, 2. он может использоваться совместно (например, при создании элементов в цикле). Если вы хотите сохранить старый конструктор, сделайте один из них делегатом для другого.

Меня формально учили поставлять геттеры и сеттеры для всех данных членов и отдавать предпочтение необработанным указателям на объекты вместо автоматических объектов.

К сожалению, вас учили неправильно.

Нет абсолютно никакой причины отдавать предпочтение указателям raw по сравнению со стандартными конструкциями библиотек, такими как std::vector<>, std::array<>или, если вам нужно std::unique_ptr<>, std::shared_ptr<>,

Самым распространенным преступником в программном обеспечении с ошибками является то, что (собственное) управление памятью обнаруживает недостатки, и, что еще хуже, их обычно трудно отладить.

Другие вопросы по тегам