Usar clases de amigos para encapsular funciones de miembros privados en C ++: ¿buenas prácticas o abuso?

12

Entonces noté que es posible evitar poner funciones privadas en los encabezados haciendo algo como esto:

// In file pred_list.h:
    class PredicateList
    {
        int somePrivateField;
        friend class PredicateList_HelperFunctions;
    public:
        bool match();
    } 

// In file pred_list.cpp:
    class PredicateList_HelperFunctions
    {
        static bool fullMatch(PredicateList& p)
        {
            return p.somePrivateField == 5; // or whatever
        }
    }

    bool PredicateList::match()
    {
        return PredicateList_HelperFunctions::fullMatch(*this);
    }

La función privada nunca se declara en el encabezado, y los consumidores de la clase que importa el encabezado nunca necesitan saber que existe. Esto es necesario si la función auxiliar es una plantilla (la alternativa es poner el código completo en el encabezado), que es cómo "descubrí" esto. Otra ventaja de no tener que volver a compilar todos los archivos que incluyen el encabezado si agrega / elimina / modifica una función de miembro privado. Todas las funciones privadas están en el archivo .cpp.

Entonces...

  1. ¿Es este un patrón de diseño conocido para el que hay un nombre?
  2. Para mí (proveniente de un fondo de Java / C # y aprendiendo C ++ en mi propio tiempo), esto parece algo muy bueno, ya que el encabezado define una interfaz, mientras que .cpp define una implementación (y el tiempo de compilación mejorado es Un buen bono). Sin embargo, también huele como si estuviera abusando de una función de lenguaje que no está destinada a usarse de esa manera. Entonces, cual es? ¿Es esto algo que desaprobaría ver en un proyecto profesional de C ++?
  3. ¿Alguna trampa que no esté pensando?

Soy consciente de Pimpl, que es una forma mucho más robusta de ocultar la implementación en el borde de la biblioteca. Esto es más para usar con clases internas, donde Pimpl causaría problemas de rendimiento o no funcionaría porque la clase debe tratarse como un valor.


EDITAR 2: la excelente respuesta de Dragon Energy a continuación sugiere la siguiente solución, que no utiliza la friendpalabra clave en absoluto:

// In file pred_list.h:
    class PredicateList
    {
        int somePrivateField;
        class Private;
    public:
        bool match();
    } 

// In file pred_list.cpp:
    class PredicateList::Private
    {
    public:
        static bool fullMatch(PredicateList& p)
        {
            return p.somePrivateField == 5; // or whatever
        }
    }

    bool PredicateList::match()
    {
        return PredicateList::Private::fullMatch(*this);
    }

Esto evita el factor de choque de friend(que parece haber sido demonizado goto) al tiempo que mantiene el mismo principio de separación.

Robert Fraser
fuente
2
" Un consumidor podría definir su propia clase PredicateList_HelperFunctions y permitirle acceder a los campos privados " . ¿No sería una violación de ODR ? Tanto usted como el consumidor tendrían que definir la misma clase. Si esas definiciones no son iguales, entonces el código está mal formado.
Nicol Bolas

Respuestas:

13

Es un poco esotérico, por decir lo menos, como ya reconociste, lo que podría hacer que me rasque la cabeza por un momento cuando empiezo a encontrar tu código preguntándome qué estás haciendo y dónde se implementan estas clases auxiliares hasta que empiezo a elegir tu estilo. / hábitos (en ese momento podría acostumbrarme por completo).

Me gusta que estés reduciendo la cantidad de información en los encabezados. Especialmente en bases de código muy grandes, que pueden tener efectos prácticos para reducir las dependencias de tiempo de compilación y, en última instancia, los tiempos de compilación.

Sin embargo, mi reacción instintiva es que si siente la necesidad de ocultar los detalles de implementación de esta manera, para favorecer el paso de parámetros a funciones independientes con vinculación interna en el archivo fuente. Por lo general, puede implementar funciones de utilidad (o clases completas) útiles para implementar una clase en particular sin tener acceso a todos los componentes internos de la clase y, en su lugar, simplemente pasar las relevantes desde la implementación de un método a la función (o constructor). Y, naturalmente, eso tiene la ventaja de reducir el acoplamiento entre su clase y los "ayudantes". También tiene una tendencia a generalizar lo que de otro modo podrían haber sido "ayudantes" si descubres que están comenzando a cumplir un propósito más general aplicable a la implementación de más de una clase.

También a veces me estremezco un poco cuando veo muchos "ayudantes" en el código. No siempre es cierto, pero a veces pueden ser sintomáticos de un desarrollador que simplemente descompone funciones de todas formas para eliminar la duplicación de código con grandes cantidades de datos que se pasan a funciones con nombres / propósitos apenas comprensibles más allá del hecho de que reducen la cantidad de código requerido para implementar algunas otras funciones. Solo un poco más de pensamiento por adelantado a veces puede conducir a una claridad mucho mayor en términos de cómo la implementación de una clase se descompone en funciones adicionales, y favorecer el paso de parámetros específicos para pasar instancias completas de su objeto con acceso completo a elementos internos puede ayudar Promover ese estilo de pensamiento de diseño. No estoy sugiriendo que estés haciendo eso, por supuesto (no tengo idea),

Si eso se vuelve difícil de manejar, consideraría una segunda solución más idiomática que es el pimpl (me doy cuenta de que mencionó problemas con él, pero creo que puede generalizar una solución para evitar aquellos con un esfuerzo mínimo). Eso puede mover una gran cantidad de información que su clase necesita ser implementada, incluidos sus datos privados fuera del encabezado al por mayor. Los problemas de rendimiento del pimpl pueden mitigarse en gran medida con un asignador de tiempo constante * barato como una lista gratuita, al tiempo que conserva la semántica de valor sin tener que implementar un copiador definido por el usuario completo.

  • Para el aspecto de rendimiento, el pimpl introduce un puntero por lo menos, pero creo que los casos tienen que ser bastante serios cuando plantea una preocupación práctica. Si la localidad espacial no se degrada significativamente a través del asignador, entonces sus bucles estrechos que iteran sobre el objeto (que generalmente deberían ser homogéneos si el rendimiento es una gran preocupación) aún tenderán a minimizar las fallas de caché en la práctica siempre que use algo como una lista gratuita para asignar el pimpl, colocando los campos de la clase en bloques de memoria en gran medida contiguos.

Personalmente, solo después de agotar esas posibilidades consideraría algo como esto. Creo que es una idea decente si la alternativa es como métodos más privados expuestos al encabezado, y tal vez solo la naturaleza esotérica sea la preocupación práctica.

Una alternativa

Una alternativa que apareció en mi cabeza en este momento que cumple en gran medida tus mismos propósitos en ausencia de amigos es así:

struct PredicateListData
{
     int somePrivateField;
};

class PredicateList
{
    PredicateListData data;
public:
    bool match() const;
};

// In source file:
static bool fullMatch(const PredicateListData& p)
{
     // Can access p.somePrivateField here.
}

bool PredicateList::match() const
{
     return fullMatch(data);
}

Ahora eso puede parecer una diferencia muy discutible y todavía lo llamaría un "ayudante" (en un sentido posiblemente despectivo, ya que todavía estamos pasando todo el estado interno de la clase a la función, ya sea que lo necesite todo o no) excepto que evita el factor de "choque" del encuentro friend. En general, friendparece un poco aterrador ver con frecuencia la ausencia de una inspección adicional, ya que dice que los elementos internos de su clase son accesibles en otro lugar (lo que implica que podría ser incapaz de mantener sus propios invariantes). Con la forma en que lo usa, friendse vuelve bastante discutible si las personas conocen la práctica desdefriendsolo reside en el mismo archivo fuente que ayuda a implementar la funcionalidad privada de la clase, pero lo anterior logra el mismo efecto al menos con el beneficio posiblemente discutible de que no involucra a ningún amigo que evite todo ese tipo ("Oh dispara, esta clase tiene un amigo. ¿Dónde más se accede / muta a sus partes privadas? "). Mientras que la versión inmediatamente anterior comunica inmediatamente que no hay forma de acceder / mutar a los privados fuera de todo lo que se haga en la implementación de PredicateList.

Tal vez se esté moviendo hacia territorios algo dogmáticos con este nivel de matiz, ya que cualquiera puede descubrir rápidamente si nombra de manera uniforme las cosas *Helper*y las coloca en el mismo archivo fuente que todo se agrupa como parte de la implementación privada de una clase. Pero si nos volvemos quisquillosos, entonces quizás el estilo inmediatamente anterior no causará tanta reacción instintiva a simple vista sin la friendpalabra clave que tiende a parecer un poco aterradora.

Para las otras preguntas:

Un consumidor podría definir su propia clase PredicateList_HelperFunctions y permitirle acceder a los campos privados. Si bien no veo esto como un gran problema (si realmente quisieras en esos campos privados, podrías hacer un casting), ¿tal vez alentaría a los consumidores a usarlo de esa manera?

Esa podría ser una posibilidad a través de los límites de la API donde el cliente podría definir una segunda clase con el mismo nombre y obtener acceso a las partes internas de esa manera sin errores de enlace. Por otra parte, en gran medida soy un codificador en C que trabaja en gráficos donde las preocupaciones de seguridad en este nivel de "qué pasaría si" están muy bajas en la lista de prioridades, por lo que las preocupaciones como estas son solo las que tiendo a agitar mis manos y bailar y intenta fingir que no existen. :-D Si está trabajando en un dominio donde las preocupaciones como estas son bastante serias, creo que es una consideración decente.

La propuesta alternativa anterior también evita sufrir este problema. Sin embargo, si todavía desea seguir usando friend, también puede evitar ese problema haciendo que el asistente sea una clase privada anidada.

class PredicateList
{
    ...

    // Declare nested class.
    class Helper;

    // Make it a friend.
    friend class Helper;

public:
    ...
};

// In source file:
class PredicateList::Helper
{
    ...
};

¿Es este un patrón de diseño conocido para el que hay un nombre?

Ninguno que yo sepa. Dudo que haya una, ya que realmente se está metiendo en los detalles de implementación y estilo.

"Ayudante del infierno"

Recibí una solicitud de mayor aclaración sobre el punto de cómo a veces me avergüenzo cuando veo implementaciones con mucho código "auxiliar", y eso podría ser un poco controvertido con algunos, pero en realidad es un hecho, ya que realmente me avergoncé cuando estaba depurando algunos de la implementación de mis colegas de una clase solo para encontrar un montón de "ayudantes" :-D Y no fui el único en el equipo rascándome la cabeza tratando de averiguar qué se supone que deben hacer exactamente todos estos ayudantes. Tampoco quiero salir dogmático como "No usarás ayudantes", pero haría una pequeña sugerencia de que podría ayudar pensar en cómo implementar cosas ausentes de ellos cuando sea práctico.

¿No son todas las funciones de miembro privado funciones auxiliares por definición?

Y sí, estoy incluyendo métodos privados. Si veo una clase con una interfaz pública como sencillo, pero como un juego sin fin de métodos privados que son algo mal definido en el propósito, como find_implo find_detail, o find_helper, entonces yo también temblar de una manera similar.

Lo que estoy sugiriendo como alternativa son las funciones no miembro no miembro con enlace interno (declarado statico dentro de un espacio de nombres anónimo) para ayudar a implementar su clase con al menos un propósito más generalizado que "una función que ayuda a implementar otros". Y puedo citar Herb Sutter de C ++ 'Coding Standards' aquí por qué eso puede ser preferible desde un punto de vista general de SE:

Evite las cuotas de membresía: siempre que sea posible, prefiera hacer funciones de no miembros que no sean amigos. [...] Las funciones no miembro no miembro mejoran la encapsulación al minimizar las dependencias: el cuerpo de la función no puede depender de los miembros no públicos de la clase (ver el Artículo 11). También separan las clases monolíticas para liberar la funcionalidad separable, lo que reduce aún más el acoplamiento (ver Artículo 33).

También puede comprender las "cuotas de membresía" de las que habla en cierto grado en términos del principio básico de reducir el alcance variable. Si imagina, como el ejemplo más extremo, un objeto de Dios que tiene todo el código requerido para que se ejecute todo su programa, entonces favorece a los "ayudantes" de este tipo (funciones, ya sean funciones de miembros o amigos) que pueden acceder a todos los elementos internos ( privados) de una clase básicamente hacen que esas variables no sean menos problemáticas que las variables globales. Tiene todas las dificultades para administrar el estado correctamente y la seguridad de subprocesos y mantener invariantes que obtendría con las variables globales en este ejemplo extremo. Y, por supuesto, la mayoría de los ejemplos reales no están tan cerca de este extremo, pero ocultar información es tan útil como limitar el alcance de la información a la que se accede.

Ahora Sutter ya da una buena explicación aquí, pero también agregaría que el desacoplamiento tiende a promover como una mejora psicológica (al menos si su cerebro funciona como el mío) en términos de cómo diseña las funciones. Cuando comienza a diseñar funciones que no pueden acceder a todo en la clase, excepto solo los parámetros relevantes que lo pasa o, si pasa la instancia de la clase como parámetro, solo sus miembros públicos, tiende a promover una mentalidad de diseño que favorece funciones que tienen un propósito más claro, además del desacoplamiento y la promoción de una encapsulación mejorada, de lo que de otro modo podría verse tentado a diseñar si pudiera acceder a todo.

Si volvemos a las extremidades, una base de código plagada de variables globales no tienta exactamente a los desarrolladores a diseñar funciones de una manera clara y generalizada en su propósito. Muy rápidamente, mientras más información pueda acceder en una función, muchos de nosotros los mortales nos enfrentamos a la tentación de desgeneralizarla y reducir su claridad a favor de acceder a toda esta información adicional que tenemos en lugar de aceptar parámetros más específicos y relevantes para esa función. para reducir su acceso al estado y ampliar su aplicabilidad y mejorar su claridad de intenciones. Eso se aplica (aunque generalmente en menor grado) con las funciones de miembros o amigos.

Dragon Energy
fuente
1
¡Gracias por el aporte! Sin embargo, no entiendo totalmente de dónde vienes con esta parte: "A veces también me estremezco un poco cuando veo muchos" ayudantes "en el código". - ¿No son todas las funciones de miembro privado funciones auxiliares por definición? Esto parece estar en desacuerdo con las funciones de miembros privados en general.
Robert Fraser
1
Ah, la clase interna no necesita "amigo" en absoluto, por lo que hacerlo de esa manera evita totalmente la palabra clave "amigo"
Robert Fraser
"¿No son todas las funciones privadas funciones auxiliares por definición? Esto parece estar en desacuerdo con las funciones privadas en general". No es lo más importante. Solía ​​pensar que era una necesidad práctica que, para una implementación de clase no trivial, tengas varias funciones privadas o ayudantes con acceso a todos los miembros de la clase a la vez. Pero miré el estilo de algunos de los grandes como Linus Torvalds, John Carmack, y aunque los códigos anteriores en C, cuando codifica el equivalente analógico de un objeto, se las arregla para codificarlo generalmente con ninguno junto con Carmack.
Dragon Energy
Y, naturalmente, creo que los ayudantes en el archivo de origen son preferibles a un encabezado masivo que incluye muchos más encabezados externos que los necesarios porque utiliza muchas funciones privadas para ayudar a implementar la clase. Pero después de estudiar el estilo de los anteriores y otros, me di cuenta de que a menudo es posible escribir funciones que son un poco más generalizadas que los tipos que necesitan acceso a todos los miembros internos de una clase, incluso para implementar una sola clase, y el pensamiento por adelantado nombrar bien la función y pasarla a los miembros específicos que necesita para trabajar a menudo termina ahorrando más tiempo
Dragon Energy
[...] de lo que se necesita, proporcionando una implementación más clara en general que es más fácil de manipular más adelante. Es como en lugar de escribir un "predicado auxiliar" para "coincidencia completa" que accede a todo en su PredicateList, a menudo podría ser factible simplemente pasar un miembro o dos de la lista de predicados a una función un poco más generalizada que no necesita acceso a cada miembro privado de PredicateList, y a menudo eso tenderá a producir también un nombre y un propósito más claros y generalizados para esa función interna, así como más oportunidades para la "reutilización del código retrospectivo".
Dragon Energy