¿Las funciones virtuales en línea realmente no tienen sentido?

172

Recibí esta pregunta cuando recibí un comentario de revisión de código que decía que las funciones virtuales no necesitan estar en línea.

Pensé que las funciones virtuales en línea podrían ser útiles en escenarios donde las funciones se invocan directamente en los objetos. Pero el contraargumento me vino a la mente: ¿por qué querríamos definir lo virtual y luego usar objetos para llamar a los métodos?

¿Es mejor no usar funciones virtuales en línea, ya que casi nunca se expanden de todos modos?

Fragmento de código que utilicé para el análisis:

class Temp
{
public:

    virtual ~Temp()
    {
    }
    virtual void myVirtualFunction() const
    {
        cout<<"Temp::myVirtualFunction"<<endl;
    }

};

class TempDerived : public Temp
{
public:

    void myVirtualFunction() const
    {
        cout<<"TempDerived::myVirtualFunction"<<endl;
    }

};

int main(void) 
{
    TempDerived aDerivedObj;
    //Compiler thinks it's safe to expand the virtual functions
    aDerivedObj.myVirtualFunction();

    //type of object Temp points to is always known;
    //does compiler still expand virtual functions?
    //I doubt compiler would be this much intelligent!
    Temp* pTemp = &aDerivedObj;
    pTemp->myVirtualFunction();

    return 0;
}
aJ.
fuente
1
Considere compilar un ejemplo con los interruptores que necesite para obtener una lista de ensambladores y luego mostrarle al revisor de código que, de hecho, el compilador puede incorporar funciones virtuales.
Thomas L Holaday
1
Lo anterior generalmente no estará en línea, porque está llamando a la función virtual en ayuda de la clase base. Aunque solo depende de lo inteligente que sea el compilador. Si pudiera señalar que pTemp->myVirtualFunction()podría resolverse como una llamada no virtual, podría tener esa llamada en línea. Esta llamada referenciada está en línea con g ++ 3.4.2: TempDerived & pTemp = aDerivedObj; pTemp.myVirtualFunction();su código no lo está.
doc
1
Una cosa que realmente hace gcc es comparar la entrada vtable con un símbolo específico y luego usar una variante en línea en un bucle si coincide. Esto es especialmente útil si la función en línea está vacía y el bucle puede eliminarse en este caso.
Simon Richter
1
@doc El compilador moderno se esfuerza por determinar en tiempo de compilación los posibles valores de los punteros. El solo uso de un puntero no es suficiente para evitar la inserción en cualquier nivel de optimización significativo; ¡GCC incluso realiza simplificaciones en la optimización cero!
curioso

Respuestas:

153

Las funciones virtuales pueden estar en línea a veces. Un extracto de las excelentes preguntas frecuentes de C ++ :

"El único momento en que una llamada virtual en línea puede estar en línea es cuando el compilador conoce la" clase exacta "del objeto que es el objetivo de la llamada a la función virtual. Esto puede suceder solo cuando el compilador tiene un objeto real en lugar de un puntero o referencia a un objeto. Es decir, ya sea con un objeto local, un objeto global / estático o un objeto totalmente contenido dentro de un compuesto ".

ya23
fuente
77
Es cierto, pero vale la pena recordar que el compilador es libre de ignorar el especificador en línea, incluso si la llamada se puede resolver en el momento de la compilación y se puede insertar en línea.
sharptooth
66
Otra situación en la que creo que la alineación puede ocurrir es cuando se llama al método, por ejemplo, como este-> Temp :: myVirtualFunction (): dicha invocación omite la resolución de la tabla virtual y la función debería estar alineada sin problemas: ¿por qué y si usted ' Quiero hacerlo es otro tema :)
RnR
55
@RnR. No es necesario tener 'this->', solo usar el nombre calificado es suficiente. Y este comportamiento tiene lugar para destructores, constructores y en general para operadores de asignación (ver mi respuesta).
Richard Corden
2
sharptooth: verdadero, pero AFAIK esto es cierto para todas las funciones en línea, no solo para las funciones virtuales en línea.
Colen
2
void f (const Base & lhs, const Base & rhs) {} ------ En la implementación de la función, nunca se sabe a qué apunta lhs y rhs hasta el tiempo de ejecución.
Baiyan Huang
72

C ++ 11 ha agregado final. Esto cambia la respuesta aceptada: ya no es necesario saber la clase exacta del objeto, es suficiente saber que el objeto tiene al menos el tipo de clase en la que la función se declaró final:

class A { 
  virtual void foo();
};
class B : public A {
  inline virtual void foo() final { } 
};
class C : public B
{
};

void bar(B const& b) {
  A const& a = b; // Allowed, every B is an A.
  a.foo(); // Call to B::foo() can be inlined, even if b is actually a class C.
}
MSalters
fuente
No pude incluirlo en VS 2017.
Yola
1
No creo que funcione de esta manera. La invocación de foo () a través de un puntero / referencia de tipo A nunca puede estar en línea. Llamar a b.foo () debería permitir la inserción. A menos que esté sugiriendo que el compilador ya sabe que se trata de un tipo B porque conoce la línea anterior. Pero ese no es el uso típico.
Jeffrey Faust
Por ejemplo, compare el código generado para bar y bas aquí: godbolt.org/g/xy3rNh
Jeffrey Faust
@JeffreyFaust No hay razón para que la información no se propague, ¿verdad? Y iccparece hacerlo, según ese enlace.
Alexey Romanov
Los compiladores de @AlexeyRomanov tienen libertad para optimizar más allá del estándar, ¡y ciertamente lo hacen! Para casos simples como el anterior, el compilador podría conocer el tipo y hacer esta optimización. Raramente las cosas son así de simples, y no es típico poder determinar el tipo real de una variable polimórfica en tiempo de compilación. Creo que OP se preocupa por "en general" y no por estos casos especiales.
Jeffrey Faust
37

Hay una categoría de funciones virtuales donde todavía tiene sentido tenerlas en línea. Considere el siguiente caso:

class Base {
public:
  inline virtual ~Base () { }
};

class Derived1 : public Base {
  inline virtual ~Derived1 () { } // Implicitly calls Base::~Base ();
};

class Derived2 : public Derived1 {
  inline virtual ~Derived2 () { } // Implicitly calls Derived1::~Derived1 ();
};

void foo (Base * base) {
  delete base;             // Virtual call
}

La llamada para eliminar 'base', realizará una llamada virtual para llamar al destructor de clase derivado correcto, esta llamada no está en línea. Sin embargo, debido a que cada destructor llama a su destructor padre (que en estos casos está vacío), el compilador puede alinear esas llamadas, ya que no llaman virtualmente a las funciones de la clase base.

El mismo principio existe para los constructores de clases base o para cualquier conjunto de funciones donde la implementación derivada también llama a la implementación de clases base.

Richard Corden
fuente
23
Sin embargo, se debe tener en cuenta que las llaves vacías no siempre significan que el destructor no hace nada. Los destructores destruyen por defecto todos los objetos miembros de la clase, por lo que si tiene algunos vectores en la clase base, ¡eso podría ser bastante trabajo en esas llaves vacías!
Philip
14

He visto compiladores que no emiten ninguna tabla v si no existe ninguna función no en línea (y se define en un archivo de implementación en lugar de un encabezado). Lanzarían errores como missing vtable-for-class-Ao algo similar, y estarías confundido como el infierno, como lo estaba yo.

De hecho, eso no es conforme con el Estándar, pero sucede, así que considere colocar al menos una función virtual que no esté en el encabezado (si solo es el destructor virtual), de modo que el compilador pueda emitir una vtable para la clase en ese lugar. Sé que sucede con algunas versiones de gcc.

Como alguien mencionó, las funciones virtuales en línea pueden ser un beneficio a veces , pero, por supuesto, la usará con mayor frecuencia cuando no conozca el tipo dinámico del objeto, porque esa fue la razón principal virtualen primer lugar.

Sin embargo, el compilador no puede ignorar por completo inline. Tiene otra semántica además de acelerar una llamada de función. La línea implícita para las definiciones en clase es el mecanismo que le permite poner la definición en el encabezado: solo las inlinefunciones se pueden definir varias veces en todo el programa sin violar ninguna regla. Al final, se comporta como lo habría definido solo una vez en todo el programa, aunque haya incluido el encabezado varias veces en diferentes archivos vinculados entre sí.

Johannes Schaub - litb
fuente
11

Bueno, en realidad las funciones virtuales siempre pueden estar en línea , siempre que estén unidas estáticamente: supongamos que tenemos una clase abstracta Base con una función virtual Fy clases derivadas Derived1y Derived2:

class Base {
  virtual void F() = 0;
};

class Derived1 : public Base {
  virtual void F();
};

class Derived2 : public Base {
  virtual void F();
};

Una llamada hipotética b->F();(con btipo Base*) es obviamente virtual. Pero usted (o el compilador ...) podría reescribirlo así (supongamos que typeofes una typeidfunción similar que devuelve un valor que se puede usar en a switch)

switch (typeof(b)) {
  case Derived1: b->Derived1::F(); break; // static, inlineable call
  case Derived2: b->Derived2::F(); break; // static, inlineable call
  case Base:     assert(!"pure virtual function call!");
  default:       b->F(); break; // virtual call (dyn-loaded code)
}

Si bien todavía necesitamos RTTI para typeof, la llamada se puede alinear efectivamente, básicamente, incrustando la vtable dentro de la secuencia de instrucciones y especializando la llamada para todas las clases involucradas. Esto también podría generalizarse especializando solo unas pocas clases (por ejemplo, solo Derived1):

switch (typeof(b)) {
  case Derived1: b->Derived1::F(); break; // hot path
  default:       b->F(); break; // default virtual call, cold path
}
CAFxX
fuente
¿Son algunos compiladores que hacen esto? ¿O es solo especulación? Lo siento si soy demasiado escéptico, pero su tono en la descripción anterior suena como: "¡podrían hacer esto totalmente!", Que es diferente de "algunos compiladores hacen esto".
Alex Meiburg
Sí, Graal hace una alineación polimórfica (también para el código de bits LLVM a través de Sulong)
CAFxX
3

en línea realmente no hace nada, es una pista. El compilador podría ignorarlo o podría incluir un evento de llamada sin incluirlo si ve la implementación y le gusta esta idea. Si la claridad del código está en juego, la línea debe eliminarse.

diente filoso
fuente
2
Para los compiladores que operan solo en TU individuales, solo pueden en línea implícitamente funciones para las que tienen la definición. Una función solo se puede definir en múltiples TU si la hace en línea. 'en línea' es más que una pista y puede tener una mejora dramática en el rendimiento para una compilación de g ++ / makefile.
Richard Corden
3

Las funciones virtuales declaradas en línea están en línea cuando se llaman a través de objetos y se ignoran cuando se llaman a través de puntero o referencias.

tarachandverma
fuente
1

Con los compiladores modernos, no hará ningún daño incluirlos. Algunos combos antiguos de compiladores / enlazadores podrían haber creado múltiples vtables, pero ya no creo que sea un problema.


fuente
1

Un compilador solo puede en línea una función cuando la llamada puede resolverse sin ambigüedades en el momento de la compilación.

Sin embargo, las funciones virtuales se resuelven en tiempo de ejecución, por lo que el compilador no puede alinear la llamada, ya que en el tipo de compilación no se puede determinar el tipo dinámico (y, por lo tanto, la implementación de la función a llamar).

PaulJWilliams
fuente
1
Cuando se llama a un método de la clase base de la misma clase o que se derivan de la llamada no es ambigua y no virtual
sharptooth
1
@sharptooth: pero sería un método en línea no virtual. El compilador puede incluir funciones en línea que usted no le pide, y probablemente sepa mejor cuándo conectarse en línea o no. Déjalo decidir.
David Rodríguez - dribeas
1
@dribeas: Sí, eso es exactamente de lo que estoy hablando. Solo me opuse a la afirmación de que las búsquedas virtuales se resuelven en tiempo de ejecución; esto es cierto solo cuando la llamada se realiza prácticamente, no para la clase exacta.
sharptooth
Creo que eso no tiene sentido. Cualquier función siempre puede estar en línea, no importa cuán grande sea o si es virtual o no. Depende de cómo se escribió el compilador. Si no está de acuerdo, entonces espero que su compilador tampoco pueda producir código no en línea. Es decir: el compilador puede incluir código que en tiempo de ejecución prueba las condiciones que no pudo resolver en tiempo de compilación. Es como si los compiladores modernos pudieran resolver valores constantes / reducir expresiones numéricas en tiempo de compilación. Si una función / método no está en línea, no significa que no pueda estar en línea.
1

En los casos en que la llamada a la función no es ambigua y la función es un candidato adecuado para la inclusión, el compilador es lo suficientemente inteligente como para incorporar el código de todos modos.

El resto del tiempo "virtual en línea" no tiene sentido, y de hecho algunos compiladores no compilan ese código.

sombra de Luna
fuente
¿Qué versión de g ++ no compilará virtuales en línea?
Thomas L Holaday
Hm. El 4.1.1 que tengo aquí ahora parece ser feliz. Primero encontré problemas con esta base de código usando un 4.0.x. Supongo que mi información está desactualizada, editada.
moonshadow
0

Tiene sentido crear funciones virtuales y luego llamarlas a objetos en lugar de referencias o punteros. Scott Meyer recomienda, en su libro "c ++ efectivo", nunca redefinir una función no virtual heredada. Eso tiene sentido, porque cuando crea una clase con una función no virtual y redefine la función en una clase derivada, puede estar seguro de usarla usted mismo, pero no puede estar seguro de que otros la usarán correctamente. Además, en una fecha posterior puede usarlo incorrectamente. Por lo tanto, si realiza una función en una clase base y desea que sea redifinable, debe hacerlo virtual. Si tiene sentido realizar funciones virtuales y llamarlas a objetos, también tiene sentido alinearlas.

Baltasar
fuente
0

En realidad, en algunos casos, agregar "en línea" a una anulación final virtual puede hacer que su código no se compile, ¡así que a veces hay una diferencia (al menos en el compilador VS2017s)!

En realidad, estaba haciendo una función de anulación final virtual en línea en VS2017 agregando el estándar c ++ 17 para compilar y vincular y, por alguna razón, falló cuando utilizo dos proyectos.

Tenía un proyecto de prueba y una DLL de implementación que estoy probando. En el proyecto de prueba, tengo un archivo "linker_includes.cpp" que # incluye los archivos * .cpp del otro proyecto que se necesitan. Lo sé ... sé que puedo configurar msbuild para usar los archivos de objetos de la DLL, pero tenga en cuenta que es una solución específica de Microsoft, mientras que incluir los archivos cpp no ​​está relacionado con el sistema de compilación y es mucho más fácil de versionar un archivo cpp que los archivos xml y la configuración del proyecto y tal ...

Lo interesante es que constantemente recibía un error de vinculador del proyecto de prueba. ¡Incluso si agregué la definición de las funciones que faltan mediante copiar y pegar y no mediante include! Tan raro. El otro proyecto se ha construido y no hay conexión entre los dos que no sea marcar una referencia de proyecto, por lo que hay un orden de construcción para garantizar que ambos siempre se construyan ...

Creo que es algún tipo de error en el compilador. No tengo idea si existe en el compilador enviado con VS2020, porque estoy usando una versión anterior porque algunos SDK solo funcionan con eso correctamente :-(

Solo quería agregar que no solo marcarlos como en línea puede significar algo, sino que incluso podría hacer que su código no se construya en algunas circunstancias raras. Esto es raro, pero bueno saberlo.

PD .: El código en el que estoy trabajando está relacionado con los gráficos de computadora, así que prefiero la inline y es por eso que utilicé tanto final como inline. Mantuve el especificador final para esperar que la versión de lanzamiento sea lo suficientemente inteligente como para compilar el archivo DLL al incluirlo incluso sin que yo lo haya insinuado directamente ...

PS (Linux) .: Espero que no ocurra lo mismo en gcc o clang como solía hacer este tipo de cosas de forma rutinaria. No estoy seguro de dónde viene este problema ... prefiero hacer c ++ en Linux o al menos con algunos gcc, pero a veces el proyecto es diferente en necesidades.

prenex
fuente