Runtime Polymorphism Without Objects or Virtual Functions

栏目: IT技术 · 发布时间: 4年前

内容简介:Published May 15, 2020When thinking of polymorphism, and in particular of runtime polymorphism, the first thing that comes to mind is virtual functions.Virtual functions are very powerful, and fit for some use cases. But before using them,

Published May 15, 2020

When thinking of polymorphism, and in particular of runtime polymorphism, the first thing that comes to mind is virtual functions.

Virtual functions are very powerful, and fit for some use cases. But before using them, it’s a good thing to consider our exact need for polymorphism, and look around if there are other, more adapted tools to satisfy it.

Indeed, virtual functions create polymorphism on objects. But what if you don’t need objects? What if you only need you code to behave differently depending on some conditions, but you don’t need any objects involved?

In this case we can use something else that virtual functions.

Motivating example: choosing the right calculator

Consider the following example, which is inspired from a project I worked on. I simplified the example by stripping off everything domain related to make it easier to understand.

We have an input, and we’d like to compute an output (this is a pretty standardised example, right?). The input value looks like this:

struct Input
{
    double value;
};

And the output value looks like that:

struct Output
{
    double value;
};

To compute the Output based on the Input , we use a calculator.

There are various types of calculators, that are designed to handle various types of inputs. To make the example simple but without losing any of its generality, let’s say that there are two calculators: one that handles big inputs (with a value larger than 10) and one that handles small inputs (with a value smaller or equal to 10).

Moreover, each calculator can log some information about a given pair of input and output.

We’d like to write code that, given an Input ,

  • determines what calculator will handle it,
  • launches the calculation to produce an Output ,
  • and invokes the logging of that calculator for the Input  and the Output .

Implementing polymorphism

Given the above needs, we would need some interface to represent a Calculator, with the three following functions:

bool handles(Input const& input);
 
Output compute(Input const& input);
 
void log(Input const& input, Output const& output);

Those three functions define a calculator.

It would be nice to group those three functions in the same place, for example a class. But we don’t need them to be member functions, they can be just regular functions. If we use a class to stick them together, we can implement them as static functions.

Here is then our calculator that handles big values:

struct BigCalculator
{
   static bool handles(Input const& input)
   {
      return input.value > 10;
   }
 
   static Output compute(Input const& input)
   {
      return Output{ input.value * 5 };
   }
 
   static void log(Input const& input, Output const& output)
   {
       std::cout << "BigCalculator took an input of " << input.value << " and produced an output of " << output.value << '\n';
   }
};

And this is the one that handles small values:

struct SmallCalculator
{
   static bool handles(Input const& input)
   {
      return input.value <= 10;
   }
 
   static Output compute(Input const& input)
   {
      return Output{ input.value + 2 };
   }
 
   static void log(Input const& input, Output const& output)
   {
       std::cout << "SmallCalculator took an input of " << input.value << " and produced an output of " << output.value << '\n';
   }
};

BigCalculator and SmallCalculator are two implementations of the “Calculator” interface.

Binding the implementations with the call site

Now that we have various implementations of the Calculator interface, we need to somehow bind them to a call site, in a uniform manner.

This means that the code of a given call site should be independent of the particular calculator that it uses. This is by definition what polymorphism achieves.

So far, the “Calculator” interface was implicit. Let’s now create a component that embodies a Calculator, and that can behave either like a SmallCalculator or a BigCalculator .

This component must have the three functions of the Calculator interface, and execute the code of either BigCalculator or SmallCalculator . Let’s add three functions pointers, that we will later assign the the static functions of the calculator implementations:

struct Calculator
{
   bool (*handles) (Input const& input);
   Output (*compute)(Input const& input);
   void (*log)(Input const& input, Output const& output);
};

To make the binding with a calculator implementation easier, let’s add a helper function that assigns those function pointers to the one of a calculator:

struct Calculator
{
   bool (*handles) (Input const& input);
   Output (*compute)(Input const& input);
   void (*log)(Input const& input, Output const& output);
 
   template<typename CalculatorImplementation>
   static Calculator createFrom()
   {
      return Calculator{ &CalculatorImplementation::handles, &CalculatorImplementation::compute, &CalculatorImplementation::log };
   }
};

This function is a bit like a constructor, but instead of taking values like a normal constructor, it takes a type as input.

Instantiating the calculators

To solve our initial problem of choosing the right calculator amongst several ones, let’s instantiate and store the calculators in a collection. To do that, we’ll have a collection of Calculator s that we bind to either BigCalculator or SmallCalculator :

std::vector<Calculator> getCalculators()
{
   return {
       Calculator::createFrom<BigCalculator>(),
       Calculator::createFrom<SmallCalculator>()
       };
}

We now have a collection of calculator at the ready.

Using the calculator in polymorphic code

We can now write code that uses the Calculator interface, and that is independent from the individual types of calculators:

auto const input = Input{ 50 };
 
auto const calculators = getCalculators();
auto const calculator = std::find_if(begin(calculators), end(calculators),
                [&input](auto&& calculator){ return calculator.handles(input); });
 
if (calculator != end(calculators))
{
    auto const output = calculator->compute(input);
    calculator->log(input, output);
}

This code prints the following output ( run the code yourself here ):

BigCalculator took an input of 50 and produced an output of 250

And if we replace the first line by the following, to take a small input:

SmallCalculator took an input of 5 and produced an output of 7

We see that the code picks the correct calculator and uses it to perform the calculation and the logging.

Didn’t we reimplement virtual functions?

The above code doesn’t contain inheritance nor the keyword virtual . But it uses function pointers to route the execution to an implementation in a given class, and that sounds a lot like what virtual functions and vtables do.

Did we just manually implement virtual functions? In this case, we’d be better off using the native feature of the language rather than implementing our own.

The problem we’re trying to solve is indeed implementable with virtual functions. Here is the code to do this, with highlight on the significant differences with our previous code:

struct Input
{
    double value;
};
 
struct Output
{
    double value;
};
 
struct Calculator
{
    virtual bool handles(Input const& input) const = 0; // virtual methods
    virtual Output compute(Input const& input) const = 0;
    virtual void log(Input const& input, Output const& output) const = 0;
    virtual ~Calculator() {};
};
 
struct BigCalculator : Calculator // inheritance
{
   bool handles(Input const& input) const override
   {
      return input.value > 10;
   }
 
   Output compute(Input const& input) const override
   {
      return Output{ input.value * 5 };
   }
 
   void log(Input const& input, Output const& output) const override
   {
       std::cout << "BigCalculator took an input of " << input.value << " and produced an output of " << output.value << '\n';
   }
};
 
struct SmallCalculator : Calculator
{
   bool handles(Input const& input) const override
   {
      return input.value <= 10;
   }
 
   Output compute(Input const& input) const override
   {
      return Output{ input.value + 2 };
   }
 
   void log(Input const& input, Output const& output) const override
   {
       std::cout << "SmallCalculator took an input of " << input.value << " and produced an output of " << output.value << '\n';
   }
};
 
std::vector<std::unique_ptr<Calculator>> getCalculators() // unique_ptrs
{
   auto calculators = std::vector<std::unique_ptr<Calculator>>{};
   calculators.push_back(std::make_unique<BigCalculator>());
   calculators.push_back(std::make_unique<SmallCalculator>());
   return calculators;
}
 
int main()
{
    auto const input = Input{ 50 };
 
    auto const calculators = getCalculators();
    auto const calculator = std::find_if(begin(calculators), end(calculators),
                    [&input](auto&& calculator){ return calculator->handles(input); });
 
    if (calculator != end(calculators))
    {
        auto const output = (*calculator)->compute(input); // extra indirection
        (*calculator)->log(input, output);
    }
}

There are a few notable differences with our previous code that didn’t use virtual functions:

  • there is now inheritance,
  • calculators are now represented as pointers,
  • calculators are now allocated on the heap with  new  (in the std::unique_ptr s).

The structural difference between the two approaches is that the first one was using polymorphism on classes , or on code, whereas the one with virtual functions uses polymorphism on objects .

As a result, polymorphic objects are instantiated on the heap, in order to store them in a container. With polymorphism on classes, we didn’t instantiate any object on the heap.

Which code is better?

Using new (and delete ) can be a problem, especially for performance. Some applications are even forbidden to use heap storage for this reason.

However, if your system allows the use of new , it is preferable to write expressive code and optimise it only where necessary. And maybe in this part of the code calling new doesn’t make a significant difference.

Which solution has the most expressive code then?

Our first code using polymorphism on classes has a drawback in terms of expressiveness: it uses a non-standard construct, with the Calculator interface handling function pointers. Virtual functions, on the other hand, use only standard features that hide all this binding, and gives less code to read.

On the other hand, virtual functions don’t express our intention as precisely as polymorphism on classes does: calculators are not objects, they are functions. The solution using polymorphism with class demonstrates this, by using static functions instead of object methods.

In summary when it comes to expressiveness, there are pros and cons for both solutions. When it comes to the usage of new , one solution uses new and one doesn’t.

What do you think about those two solutions?

In any case, it is important to keep in mind that virtual functions are powerful as they allow polymorphism at object level, but they come at a cost: instantiation on the heap, and using pointers.

When you need polymorphism, don’t rush on virtual functions. Don’t rush on any design, for that matter. Think first about what you need. There may be other solutions that will match your need better.

Any feedback is appreciated.

You will also like

Share this post!&nbspDon't want to miss out ?


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

剑指Offer

剑指Offer

何海涛 / 电子工业出版社 / 2014-6-1 / CNY 55.00

《剑指Offer——名企面试官精讲典型编程题(纪念版)》是为纪念本书英文版全球发行而推出的特殊版本,在原版基础上新增大量本书英文版中的精选题目,系统整理基础知识、代码质量、解题思路、优化效率和综合能力这5个面试要点。全书分为8章,主要包括面试流程:讨论面试每一环节需要注意的问题;面试需要的基础知识:从编程语言、数据结构及算法三方面总结程序员面试知识点;高质量代码:讨论影响代码质量的3个要素(规范性......一起来看看 《剑指Offer》 这本书的介绍吧!

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

随机密码生成器
随机密码生成器

多种字符组合密码