Ocultación de datos y paso de variables

Leo en Twitter un par de preguntas más que curiosas sobre dos cosas que se dan por supuestas en el desarrollo orientado a objetos. Aunque una de ellas está formulada al revés, yo aquí le voy a dar el sentido correcto, e intentaré responder, en la medida de mis conocimientos, a ambas.

***

La primera de ellas es “¿por qué las variables de una clase no deben ser públicas?”

Es una de las primeras cosas que nos enseñan en la POO. Extendiendo la pregunta al ámbito completo, el concepto se conoce como “ocultación de datos”, y se extiende no sólo a las variables de clase, sino también a los objetos en sí y en general al programa completo.

Por poner un caso extremo, en la mayoría de Frameworks serios, el punto de entrada de un programa cualquiera debería ser:

 

MiApp theApp;

theApp.Run();

 

Vale, es un poco extremo, pero a ese nivel es lo que necesitamos: una aplicación, y que la vamos a ejecutar. No nos hace falta nada más ni tenemos que conocer nada más a ese nivel.

Es decir, uno de los motivos de la ocultación de datos es dar a conocer únicamente lo estrictamente necesario, y de hecho no nos importa si theApp tiene una variable llamada Juanito o Pepito porque no tiene utilidad conocerlo a ese nivel.

Y de hecho no nos interesa que nadie sepa la estructura interna de MiApp porque sencillamente ahí no podemos y no debemos hacer nada más que ejecutar el objeto que es la representación en memoria de nuestra aplicación. Quizás, o bien en el constructor o bien en el método Run(), pasar los argumentos con los que ha sido llamada la aplicación, pero sólo si es pertinente y nos interesa.

La regla nos dice que si no lo necesitamos a ese nivel, no lo hagamos público. Pero claro, todavía no he explicado por qué. Simplemente he definido la regla: oculta todo lo que puedas.

Jorge Serrano, respondiendo en Twitter, lo ha dicho bastante claro: No te interesa que nadie te pueda cambiar los zapatos sin tu consentimiento. Yo diría que cada perro debe lamerse su pijo, expresión más castiza. Imagina que vas andando por el monte, y alguien te cambia tus botas de trekking por unas chanclas de playa sin tu consentimiento… O que estás en una reunión de alto copete y una chavala empieza a esto…, lamerte el tema…

Reformulado en código, y retrocediendo un poco, la idea es bastante interesante.

En la programación procedural clásica (es decir, C y similares), y tal y como lo dijo Stroustrup en no recuerdo qué lugar, un programa es un conjunto de funciones que menean un conjunto de datos.

En teoría, cualquier función podría tocar cualquier dato. Digo en teoría porque en C y similares también hay métodos, más primitivos, para ocultar datos. Quien haya desarrollado aplicaciones más o menos grandes con esta filosofía, se habrá encontrado con serios problemas:

  • Métodos que cambian datos que no deberían cambiar.
  • Datos que deberían ser diferentes pero que por un despiste tienen el mismo nombre y que son combinados por el enlazador como uno solo.

Poniéndolo en otras palabras: se pueden dar situaciones en las que la modificación de un dato genere efectos laterales que terminen en un comportamiento no deseado, o incluso generar una onda de interferencia que termine tumbando la aplicación.

Lo mismo podría pasar con dos variables que accidentalmente han sido nombradas igual. Un compilador moderno debería avisar de este último caso a nivel de enlazador, y en general, al menos con Visual C++, así ocurre. Otra cosa es que el programador novel le haga caso o se dé cuenta del aviso. Y no todos los compiladores notifican de este hecho, sobre todo los de plataforma cruzada para sistemas embebidos, que suelen ser bastante antiguos.

Volviendo al tema central, la OO intenta solucionar este problema con las clases y la ocultación de datos. Es decir, una clase es una serie de métodos que definen cómo tocar a una serie de datos de forma auto contenida. Y hasta la fecha es la mejor solución que se ha podido encontrar, y si alguien conoce alguna mejor (que no sea una paja mental), le vaticino los laureles del éxito más absoluto y arrollador.

En otras palabras: una clase es una entidad con un comportamiento definido por su código y es algo opaco a cualquier cosa que intente ver su interior excepto un interfaz público que sirva para tratar con ella.

Y hay varias razones, todas ellas muy poderosas, para que esto deba ser así, lo que lleva implícito una serie de obligaciones: ocultación de datos, interfaz pública y autocontención. Otras características como polimorfismo extienden pero no obligan, por lo que las vamos a obviar.

Volviendo al ejemplo de los zapatos, pensemos en un objeto zapato genérico. Cuando construyamos una persona, a la clase persona le añadimos dos objetos zapatos (que podrían ir en un array, pero no es imprescindible).

Y ahora viene el truco del almendruco. Atarse los cordones. Podemos hacerlo de varias formas.

La más chapucera es acceder al método miembro cordón de cada zapato y atarlo, ejecutando una serie de acciones (código) sobre dichos cordones. Para ello necesitamos que cordón sea público.

¿Pero qué pasa si tenemos varios tipos de zapatos? Por ejemplo, unos que lleven hebillas. Ups. El código para atar zapatos que está en la clase persona ya no nos sirve…

Si embargo, si definimos un zapato padre que tenga un método llamado AtarCordones() y que internamente use el dato miembro cordón para realizar la misma operación que antes hacíamos desde la clase persona, cuando heredemos de dicho zapato una nueva clase llamada sandalia, tan sólo tendremos que redefinir el método AtarCordones() que, en lugar de usar cordón, usará hebilla y una serie de acciones con ellas.

Y lo que es más importante, la clase contenedora, persona, se desentiende de qué zapato tenemos, de si tiene hebillas o cordones. Simplemente llama a zapato.AtarCordones() y la acción estará hecha.

[Hablando más seriamente, deberíamos tener una clase base virtual llamada, por ejemplo, ZapatoGenérico que defina un método virtual llamado AtatCordones(). Y luego deberíamos heredar de ahí el Zapato normal y la Sandalia. Y quizás otras variantes de calzado.]

Por lo tanto, al ocultar los datos internos y dejar una interfaz pública, ganamos muchas cosas:

  • Los objetos que manipulen nuestro objeto lo harán a través de una interfaz definida y que debe ser siempre la misma (con la salvedad de los pasos de refactorización que sean necesarios y que cambien los nombres o la propia interfaz).
  • El punto de arriba mejora la claridad del código,
  • Nos evita tener que depurar los objetos llamadores porque no ha cambiado nada en ellos,
  • Nos permite cambiar el comportamiento interno del objeto manipulado sin tener que preocuparnos de la clase manejadora,
  • Permite el efecto biblioteca (comentado por Herb Sutter en su Rationale a C++/CLI, que su vez lo toma de Stroustrup): podemos meter la clase Zapato en una biblioteca, y podremos cambiar su comportamiento interno sin tener que recompilar el código que la use,
  • Oculta la visibilidad, permitiendo un código mucho más limpio y con menos interferencias y símbolos globales, que al menos en C++ pueden lentificar la compilación en grado sumo.
  • Acelera la carga en tiempo de ejecución, porque el cargador del sistema, al tener menos símbolos públicos, tiene que inicializar y relacionar menos cosas.

Vale, creo que no se me escapa nada, y si lo hiciera, con los puntos de arriba hay suficiente.

Todo esto nos lleva a una serie de reglas de diseño con las clases que, al menos yo, llevo a rajatabla:

  • Oculta todo lo que puedas.
  • Usa el nivel más cerrado posible: private en C++ para todo lo que puedas. Luego, cuando refactorices y necesites que algo sea conocido por clases hijas, pásalo a protected, pero con sumo cuidado ya que si hay (o se quiere) efecto lateral, mejor pon un método protegido en el padre que pueda ser llamado por el hijo para acceder a ese miembro privado.
  • Define interfaces públicos, pero los menos posibles. Si un zapato no va desatar los cordones, no lo definas. Si lo necesitas, ya lo definirás. (Aquí hay que mantener cierto equilibrio, porque si sabes que lo puedes necesitar, mejor lo declaras pero no lo defines –cuerpo vacío-, ya que podría ser peor luego tener que cambiar la interfaz ya definida y usada).
  • Documenta por qué, no cómo (el cómo ya te lo dice el propio código, a no ser que sea algún algoritmo complejo, y en ese caso en el 99% de las veces seguro que se puede hacer de otra forma más sencilla).

Finalmente esto nos lleva a un problema que plantea mucha gente, y es que toda esa ocultación y esos métodos de acceso pueden volver un programa extremadamente lento.

Os lo puedo asegurar: eso fue en tiempos pasados, cuando los compiladores eran animales mitológicos y se sabía poco sobre ellos.  Ahora, cualquier compilador medio decente se va a comer tus miles de líneas de código en un santiamén y va a generar mejor código que el que tu pudieras hacer a mano saltándote todos esos pasos. Al menos en C++ y quiero creer que también en C#.

***

Bueno, ahora viene la segunda pregunta, que reformulo pues está planteada con los conceptos cambiados: “¿Alguno sabe la razón de por qué las variables se pasan por valor y los objetos por referencia?” Hablamos de C#, ya que en C++ y C++/CLI podemos pasarlos como nos salga de la pepitilla…

Para responder a esta pregunta debemos meternos un poco en cómo funcionan los compiladores.

Una variable nativa, digamos un entero, ocupa 4 bytes (por decir algo, todo depende de dónde ejecutemos). Un objeto ocupa tanto como la suma de sus datos miembro y una o varias vtable en caso de que tenga métodos virtuales y del nivel de anidación de la herencia (así como de lo bueno que sea el compilador).

[Una vtable es un array de punteros a función que, en tiempo de ejecución, determinan qué método se va a ejecutar dentro de un objeto con métodos virtuales. Un buen compilador resumirá esto en cambiar un call directo a un call indirecto en base a un índice almacenado en algún lugar.]

Cuando nosotros definimos una clase, y a partir de ella instanciamos un objeto, el compilador (y en tiempo de compilación) hace un pase de manos y agrupa todos los métodos miembro en un bloque que junta con los demás métodos miembro de las demás clases, añadiendo unas firmas especiales a los nombres de función.

Digamos que una vez compilado, un programa OO se convierte en un programa no OO que tiene una serie de funciones globales que acceden y modifican una serie de datos… ¿Os suena, verdad? Es que es la única forma de hacerlo, os lo puedo asegurar. La ventaja está en que, salvo oscurísimos errores del compilador que cada vez ocurren con menos frecuencia, a todos los efectos, el comportamiento final simula ser completamente orientado a objetos.

Bueno, cuando pasamos parámetros en una llamada a método, el sistema usa una pila, que es un área de la memoria especialmente destinada a las tareas descritas.

No vamos a entrar en detalles técnicos, pero la cosa funciona así: el compilador pone en la pila los datos a pasar, copiándolos quizá de otro punto de la pila. Entonces hace un call en ensamblador a la función que hemos llamado, y cuando entremos en ella, sabrá que en la cima de la pila tiene sus parámetros.

Cuando pasamos un parámetro por valor, estamos copiando dicho valor en la pila. Si es un entero, ocupará 4 bytes. Si es un objeto, ocupará tantos bytes como datos miembro tenga (ojo, sólo los datos, no el código), más las tablas virtuales más algún que otro elemento más que hace la función de metadatos del objeto pasado.

Si el objeto ocupa 10 bytes, se copiarán, y si ocupa 100, 1K o 100K, se tendrán que copiar absolutamente todos los bytes. Dependiendo de qué objeto, la copia podría ser onerosa en tiempo de ejecución y gasto de memoria.

Cuando pasamos un objeto por referencia, estamos pasando un puntero al dato. No importa en qué lenguaje estemos, siempre es un puntero. Todo lo demás lo hace el sugar syntax del lenguaje, que nos endulza la sintaxis y el operar con ellos.

En este caso, de media, una referencia a un objeto en C#, y si no lo han cambiado, ocupa unos 10 bytes independientemente de qué tamaño real tenga el objeto en sí.

Por lo tanto, ahora vemos por qué en C# un objeto se pasa por referencia y un tipo nativo por valor: optimización.

Esto genera una serie de idiosincrasias en C# que al menos a mi no me gustan mucho, ya que estamos obligando al programador a que lo haga al estilo del lenguaje y no como uno quiera (ya sabéis, eso de “programar en” y “programar con”).

La optimización es la adecuada para la mayoría de casos, pero a veces querríamos hacerlo de otra forma, y es cuando uno ve las limitaciones del lenguaje. En el caso que nos ocupa, un objeto no se puede pasar por valor, lo que fuerza a copiarlo a mano si vamos a modificarlo y no queremos que el original sufra cambios. Y a veces eso es difícil, ya que en algunos casos la copia es de nuevo por valor y no se produce la mutación a nuevo objeto cuando modificamos la referencia creada, y no podemos hacer nada porque el C# (y el .NET) son así.

Sin embargo sí que podemos pasar un tipo nativo por referencia, añadiendo ref al parámetro (con lo cual modificaremos su valor desde dentro de la función llamada). No obstante, la implementación es una chapuza como un castillo, ya que la cosa funciona así dadas las severas limitaciones de la máquina virtual .NET: El valor a pasar por referencia se copia al montículo (heap en inglés, que es donde se guardan los objetos y demás elementos instanciados por referencia) y entonces se pasa, en la pila, una referencia a ese valor puesto en el montículo. Cuando salimos de la función llamada, el sistema coge del montículo el valor modificado y lo vuelve a poner en la pila.

Es lo que se llama box/unbox, y viene determinado por la limitación del .NET de acceder a la pila cuando el elemento a tocar no está encima de ella. La única ventaja que obtenemos de esto es que es mucho más difícil generar una inyección de código mediante el envenenamiento de los parámetros de retorno de la pila.

***

Para aquellos que quieran tener una visión más amplia de la orientación a objetos, así como muchas explicaciones, les recomiendo el libro de Bertrand Meyer, Construcción de software orientado a objetos Segunda Edición, que es un tocho de más de mil páginas denso como él solo… Pese al proselitismo hacia Eiffel, es un gran libro. Hay edición en castellano de Prentice Hall (que es la que yo tengo), pero no sé si se podrá encontrar o no.

Otro no menor pero más práctico, es Code Complete 2 de Steven C. McConnell. Este es mucho más práctico y orientado hacia el código real que escribimos las personas normales.

8 comentarios sobre “Ocultación de datos y paso de variables”

  1. >Por lo tanto, ahora vemos por qué en C# un objeto se pasa por referencia y un tipo nativo por valor: optimización.

    ¿Seguro? Yo creo que es por sencillez del lenguaje. Las referencias están ocultas en C#, para el desarrollador, no existen. Entonces partiendo de esta premisa, pero SOLO PARTIENDO DE ELLA, llegas a la conclusión de que los objetos deben ser pasados por referencia por optimización (para evitar la copia y todo lo que brillantemente expones en el post). Es decir, sí, es por optimización pero porque ANTES partimos de otra premisa: referencias ocultas (por sencillez).

    Como bien dices en C++ los objetos pueden pasarse por valor o por referencia. Porque en C++ no hay la premisa de la sencillez (a veces parece que haya la contraria :p). Las referencias son EXPLICITAS, visibles para el desarrollador. Con referencias explícitas (me da igual si en forma de referencia pura o de puntero) entonces hay un mecanismo natural dentro del lenguaje para, en lugar de decir «este parámetro es de tipo X», decir «este parámetro es una referencia a un tipo X».

    Buen post!
    Saludos! 😉

    pd: Si estoy en una reunión de alto copete y viene una chavala a lamerme esto… el tema, no se tio… Yo creo que dejo que siga. Total, si ya ha empezado… 😛

  2. Bueno, a ver, que los designios de MS son inescrutables. 😉

    Creo que Sutter, en el rationale de C++/CLI dice algo sobre el tema. Si no me fallan las meninges, MS quería una máquina virtual «sencilla», en la que no hubiera nada que no fuera un objeto… Pero luego llegaron a la conclusión de que eso no era posible por temas de rendimiento e hicieron la dualidad onda-corpúsculo en el que un tipo nativo es a la vez un tipo nativo y un objeto… y a partir de ahí la liaron parda. Añade el hecho de que un tipo hereda de ValueObject que es un tipo-valor que a su vez hereda de Object que es un tipo-referencia, realizando una extraña mutación por el camino…

    Al final, la cruda realidad es que no creo que ni ellos mismos tengan claro porqué es así o asá, porque en el caso de C++/CLI tenemos que la sintaxis se mueve por referencias sin mayor complicación, y le dan al programador la sensación de que está trabajando con ellas. En C# no lo sabes a ciencia cierta, y eso puede crear situaciones un tanto estrambóticas como que haya gente que se queje de que su objeto pasado «por valor» (que es lo que el programador ve) modifique el externo dentro del método…

  3. Creo que olvidas lo que para mí es más importante de la ocultación de datos (encapsulación). Más importante todavía que lo que aporta al creador de la clase es lo que aporta al cliente de la clase. Que las interfaces de un objeto estén bien definidas simplifica el uso de la clase por parte del cliente. Con una interface bien definida es mucho más sencillo saber qué se puede y que no se puede hacer con una clase. Es mucho más sencillo aprender su funcionamiento. Si todos los miembros fuesen públicos utilizar una clase sería básicamente un ejercicio de ensayo y error.

  4. Por cierto, creo que os equivocáis totalmente acerca de por qué los objetos se pasan por referencia. Los objetos se tienen que pasar por referencia, sencillamente por el polimorfismo. El polimorfismo hace que se desconozca el tamaño del objeto hasta que se instancie. El compilador no puede decidir cuanto espacio asignar para almacenar el objeto. El espacio debe ser asignado en tiempo de ejecución. Por eso un objeto polimórfico debe almacenarse en el Heap y por eso debe utilizarse por referencia y no por valor.

  5. Siento contradecirte. En C++ el compilador sí sabe cuánto va a ocupar dicho objeto, y en el caso de C# en el que hay situaciones en las que no lo sabe (por ejemplo con los genéricos), es la máquina virtual la que podría calcular su tamaño y reservar el espacio.

    Si te das cuentas, cuando están en el montículo también se debe conocer su tamaño porque si no se pisarían unos a otros.

    En el caso de poder ir a la pila, en lugar de «saltar» la pila con un valor fijo, se «saltaría» con uno variable.

    En el caso de C# simplemente es una decisión de diseño y punto.

  6. ¿Pero como va a saber el compilador cuanto espacio ocupa una variable si el tipo que contiene puede ser cualquier tipo heredado del de la variable? El compilador conoce el tamaño de la referencia, no el del tipo apuntado. Cuando se almacenan en el Heap, evidentemente hay un momento en el que se conoce el tamaño, pero es en tiempo de ejecución, no de compilación, durante la instanciación de la clase concreta. Es sencillo para el compilador saber cuanto ocupa una referencia a una variable, no así el espacio del objeto al que apunta. Por eso en .Net existe una jerarquía de objetos que parte de Object.

    Por eso los tipos valor en .Net no admiten herencia, por que eso impediría conocer el tamaño del objeto.

    En cuanto a lo del boxing/unboxing, permite que se puedan utilizar tipos valor de forma polimórfica. Un parámetro, un miembro, o el tipo de retorno de un método se puede definir como Object y pese a todo devolver un tipo valor si es necesario. Lo que hace .Net por debajo es encapsular/desencapsular el tipo valor en un tipo referencia, de forma «casi» totalmente transparente.

  7. Por cierto, corregir unos cuantos detalles.

    Primero el comportamiento que dices que tienen en C# los «tipos nativos», no sólo lo tienen éstos, sino todos los tipos valor, y eso incluye las estructuras, no sólo los tipos nativos.

    Por otro lado, cuando se pasa en C# por referencia un tipo valor, no se copia al Heap. No se hace boxing, tal y como afirmas. Se pasa la referencia tal y como se haría en C o en C++. La copia que describes sólo se produce cuando se referencia el tipo valor desde un tipo referencia, por ejemplo si declaras el parámetro como Object o mediante una interface.

Responder a jcbadiola Cancelar respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *