Может, я неправильно распорядился наследством?


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

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

Мой go-to подход заключается в использовании наследования:

class Account{
   string name;
   virtual int getBalance() =0; //generic base class has no implementation
   virtual void addToBalance(int amount) =0;
};
class ParentAccount : public Account{
   vector<Account*> children;
   virtual int getBalance() {
      int result = 0;
      for (int i = 0; i < children.size(); i++)
          result += children[i]->getBalance();
      return result;
   }
   virtual void addToBalance(int amount) {
      cout << "Error: Cannot modify balance of a parent account" << endl;
      exit(1);
   }
};
class ChildAccount : public Account{
   int balance;
   virtual int getBalance() { return balance; }
   virtual void addToBalance(int amount) {balance += amount;}
};
Идея заключается в том, что во время компиляции неизвестно, какие учетные записи присутствуют, поэтому древовидная учетная запись должна создаваться динамически. Наследование полезно здесь, потому что оно позволяет легко генерировать произвольно глубокую древовидную структуру (ParentAccounts могут иметь потомков, которые являются ParentAccounts), а также потому, что оно позволяет легко реализовать функции, такие как getBalance(), используя рекурсию.

Все становится немного неловко, когда я пытаюсь включить функции, уникальные для производных классов, такие как изменение баланса (что должно быть возможно только для объектов ChildAccount, поскольку ParentAccount балансы просто определяются балансами их потомков). Мой план заключается в том, что функция, подобная processTransaction(string accountName, int amount), будет искать в древовидной структуре учетную запись с правильным именем, а затем вызывать addToBalance(amount) на этой учетной записи (*Примечание ниже). Поскольку приведенная выше древовидная структура позволит мне найти только Account*, необходимо либо реализовать addToBalance(amount) для всех классы, как я сделал выше, или к dynamic_cast Account* к ChildAccount* перед вызовом addToBalance(). Первый вариант кажется немного более элегантным, но тот факт, что он требует от меня определения ParentAccount::addToBalance() (хотя и как ошибка), кажется мне немного странным.

Мой вопрос таков: есть ли название для этой неловкости и стандартный подход для ее разрешения, или я просто совершенно неправильно применяю наследование?

*Примечание: я признаю, что, вероятно, существует более эффективный способ организации счетов для поиск, но моя основная цель-создать программу, которая интуитивно понятна для интерпретации и отладки. Исходя из моего нынешнего уровня понимания, это происходит за счет вычислительной эффективности (в данном случае, по крайней мере).

3 2

3 ответа:

Да, вы правильно угадали, что это не правильный случай наследования.

virtual void addToBalance(int amount) {
   cout << "Error: Cannot modify balance of a parent account" << endl;
   exit(1);
}

Ясно указывает на то, что class ParentAccount : public Account неверно: ParentAccount не имеет отношения к счету.

Есть два способа исправить это: один-лишить наследства ParentAccount. Но последовательность показывает, что это может быть чрезмерной реакцией. Таким образом, вы можете просто исключить addToBalance() из AccountParentAccount), и иерархия будет правильной.

Конечно, это будет означать, что вам придется получить указатель ChildAccount Перед тем, как звоню addToBalance(), но ты все равно должен это сделать. Практические решения многочисленны, например, вы можете просто иметь два вектора в ParentAccount, один для других родительских счетов, другой для дочерних счетов, или использовать dynamic_cast, или... (зависит от того, что еще вы должны сделать со счетами).

Названия этой неловкости-ломка ЛСП (Принцип подстановки Лискова), или, проще говоря, ломка ИС-отношения.

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

Идея паттерна visitor^ заключается в следующем: он обеспечивает возможность для элементов сложных структур (деревьев, графов) работать по-разному в зависимости от их типа (который известен только во время выполнения), и где сама конкретная операция также может быть известна только во время выполнения. runtime, без необходимости изменять всю иерархию, к которой принадлежат элементы (т. е. избегать таких вещей, как реализация функции "ошибка" addToBalance, о которой вы подумали). (^Он имеет мало общего с посещением, поэтому, вероятно, неправильно назван - это скорее способ достижения двойной отправки для языков, которые изначально его не поддерживают.)

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

class Visitor {

  virtual void Visit(ParentAccount*) { /* do nothing by default*/ }
  virtual void Visit(ChildAccount*) { /* do nothing by default */ }
};

Теперь мы создаем специальный класс для выполнения AddToBalance только на ChildAccount s.

class AddToBalance : public Visitor {

  public:
  AddBalance(string _nameOfTarget, int _balanceToAdd) :
    nameOfTarget(_nameOfTarget), balanceToAdd(_balanceToAdd) {}

  void Visit(ChildAccount* _child) { //overrides Visit only for ChildAccount nodes
    if(child->name == _name)
      child->addToBalance(_balance); //calls a function SPECIFIC TO THE CHILD
  }

  private:
    string nameOfTarget;
    int _balanceToAdd;
};

Некоторые изменения в исходном классе учетной записи.

class Account{
   vector<Account*> children; //assume ALL Account objects could have children; \
                              //for leaf nodes (ChildAccount), this vector will be empty
   string name;
   virtual int getBalance() =0; //generic base class has no implementation

   //no addToBalance function!

   virtual void Accept(Visitor* _visitor) {
     _visitor->Visit(this);
   }
};

Обратите внимание на Функция Accept() в классе Account, которая просто принимает посетителя* в качестве аргумента и вызывает функцию посещения этого посетителя на this. Именно здесь происходит волшебство. в этот момент тип this, а также Тип _visitor будут разрешены. Если this имеет тип ChildAccount и _visitor имеет тип AddToBalance, то функция Visit, которая будет вызвана в _visitor->Visit(this);, будет void AddToBalance::Visit(ChildAccount* _child).

Который просто так называется _child->addToBalance(...);:

class ChildAccount : public Account{
   int balance;
   virtual int getBalance() { return balance; }
   virtual void addToBalance(int amount) {
     balance += amount;
   } 
};

Если бы this в void Account::Accept() было a ParentAccount, то пустая функция Visitor::Visit(ParentAccount*) было бы вызвано, так как эта функция не переопределена в AddToBalance.

Теперь нам больше не нужно определять функцию addToBalance в ParentAccount:
class ParentAccount : public Account{
   virtual int getBalance() {
      int result = 0;
      for (int i = 0; i < children.size(); i++)
          result += children[i]->getBalance();
      return result;
   }
   //no addToBalance function
};

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

void VisitWithPreOrderTraversal(Account* _node, Visitor* _visitor) {
  _node->Accept(_visitor);
  for(size_t i = 0; i < _node->children.size(); ++i)
    _node->children[i]->Accept(_visitor);

}

int main() {
  ParentAccount* root = GetRootOfAccount(...);

  AddToBalance* atb = new AddToBalance("pensky_account", 500);
  VisitWithPreOrderTraversal(atb, root);

};

Самая забавная часть-это определение вашего собственного посетителя, который делает гораздо более сложным операции (например, накопление сумм остатков только по всем дочерним счетам):

class CalculateBalances : public Visitor {

  void Visit(ChildAccount* _child) {

    balanceSum += _child->getBalance();

  }
  int CumulativeSum() {return balanceSum; }
  int balanceSum;
}

Концептуально, у вас нет дочерних и родительских учетных записей, но учетные записи и дерево объектов, из которых конечные узлы содержат указатель на фактические учетные записи.

Я бы предложил вам непосредственно представить эту структуру в коде:

class Account
{
public:
    int getBalance(); 
    void addToBalance(int amount);
// privates and implementation not shown for brevity
};


class TreeNode
{
public:
    // contains account instance on leaf nodes, and nullptr otherwise.
    Account* getAccount(); 

    // tree node members for iteration over children, adding/removing children etc

private:
    Account* _account; 
    SomeContainer _children
};

Если теперь вы хотите пересечь дерево для сбора остатков на счетах и т. д., Вы можете сделать это непосредственно в структуре дерева. Это проще и менее запутанно, чем использование маршрута через родительские учетные записи. Кроме того, ясно, что фактическое учетные записи и древовидная структура, содержащая их, - это разные вещи.