Tradicionalmente, los lenguajes de programación tipados con variables mutables han escrito la idea de función que modifica su parámetro como función que coge un pointer o una referencia al tipo del parámetro. En C, una función que coge un
solamente quiere leerlo, mientras que una función que coge un
quiere escribir a él. Esta forma de describir la mutabilidad de los parámetros es común, y nos la encontramos también en C++ y hasta en Rust y Zig. Lenguajes como C#, Java, Python o JavaScript consiguen esquivar la discusión pasando implícitamente todos los argumentos por pointer mutable. En lenguajes puramente funcionales como Haskell la anotación tampoco tiene sentido, pero por la razón contraria: si todo es inmutable el pasar por copia o por referencia es semánticamente idéntico y se vuelve tan sólo un detalle de implementación y no algo que haga falta hacer decidir cada vez al programador. Por acotar el contexto del debate, este texto se centrará en lenguajes tipados con variables mutables, donde los tipos por defecto se comportan como valores (no como referencias) y donde se puede especificar la mutabilidad de las variables y las referencias. Es decir, en el espacio de diseño de lenguajes como C, C++, Rust, D o Zig, por nombrar unos pocos.
Aunque la mayoría de lenguajes optan por pedir al programador que escriba el tipo exacto del parámetro, con sus especificadores de referencia y mutabilidad, puede que esta no sea la solución ideal al problema, ya que no refleja la intención del programador sino las instrucciones al compilador de cómo llevar a cabo esa intención. En lugar de decir al compilador que la función tiene que poder escribir al parámetro, se le dice que el parámetro es una referencia mutable a una variable, ya que esto nos permite lo primero. Por ello, son interesantes modelos alternativos para escribir los parámetros de una función que intentan ser más declarativos, centrarse más en el qué en lugar de en el cómo, como
el propuesto por Herb Sutter en la CppCon de 2020
, en el que propone anotar los parámetros como
,
,
,
o
, en lugar de con los cualificadores de const y referencia. El compilador usaría esto para hacer una traducción automática al formato real, permitiendo sin embargo que el código que escribe el programador sea más declarativo y más legible. Este formato también permitiría optimizaciones hasta ahora imposibles, como que
se comporte como una copia o como una referencia constante en función de lo barato que sea de copiar el parámetro. También tiene otras ventajas como que permite al compilador comprobar con más precisión que los parámetros están siendo usados en la función en la forma que se ha descrito, permitiendo potencialmente cazar más bugs de forma automatizada.
Sin embargo, esta forma declarativa de escribir los parámetros de una función me suscita una pregunta a la que no sé responder, pues ambas posibilidades me gustan igual de poco, y a la que tampoco he visto responder satisfactoriamente a nadie más. La pregunta es, si existe una distinción entre
e
, ¿qué significa que un parámetro sea out para un tipo no trivial? Vamos a ignorar
y
, que son optimizaciones sobre
en casos específicos, y centrarnos en los tres tipos principales de parámetro:
,
e
.
es simple. De esta variable sólo se va a leer. Es el equivalente a pasar por copia o por referencia constante.
también es bastante simple. De esta variable se va a leer y también se va a escribir. Sería el equivalente a una referencia mutable ahora mismo. Pero, ¿y
? Intuitivamente, diríamos que es un parámetro al que se va a escribir, pero del que nunca se va a leer. Y este modelo funciona muy bien con tipos triviales como
. Cuando se asigna a un tipo trivial, se ignora su valor actual y simplemente se sobrescriben los bytes con el nuevo valor. Para un tipo trivial, asignar y construir una copia son la misma operación.
¿Qué sucede sin embargo con un tipo no trivial? Sin pérdida de generalidad, usaremos
como ejemplo de tipo no trivial. Cuando se asigna a un vector, se sobrescribe su valor, pero no necesariamente sus bytes. Asignar a un vector consiste en destruir los elementos que contiene, comprobar si la memoria reservada es suficiente para el nuevo valor, si no lo es devolverla y pedir un bloque de memoria más grande, y luego copiar los nuevos elementos al bloque de memoria. Asignar a un vector necesariamente requiere leer su valor para poder llevar a cabo una serie de operaciones para asegurarse de que se mantienen las invariantes de la clase. Lo mismo suceden con otras operaciones como
, que desde el punto de vista semántico sobrescriben el valor actual sin tener en cuenta cuál es, pero como parte de la implementación necesitan leer los bytes de la clase. Esto nos deja ante la siguiente encrucijada: es el significado de out semántico, es decir, el significado de “leer” y “escribir” son abstractos para tipos de dato abstractos, y no necesariamente significan leer y escribir bytes de memoria, o bien el significado de out es sintáctico, y por lo tanto ninguna operación sobre un tipo de dato abstracto es out a excepción del constructor. Ambos tienen implicaciones complejas.
Si el significado de leer y escribir es abstracto y se escapa a lo que el compilador puede razonar, no puede diagnosticar cuando se está haciendo mal. En algún momento el compilador tiene que confiar en que efectivamente el programador está respetando su promesa de no leer el valor de la clase, en lugar de diagnosticar potenciales errores, lo cual puede ser catastrófico. También se corre el riesgo de hacer al programador llegar a preguntas muy difíciles de responder. Para un tipo de dato, sea abstracto o no, es muy fácil definir cuál es su valor, y por lo tanto hablar sobre si su valor está siendo leído y/o escrito. Pero, ¿y para un mecanismo? ¿Qué significa “leer” y “escribir” para un tipo que no implementa el operador
porque no tendría sentido para él? ¿Cuál es el valor de un socket, de un file descriptor, de un buffer en la tarjeta gráfica, de un allocator, de una thread pool?
Por el otro lado,
corre el riesgo de ser entre inútil o marginalmente útil, mientras que se carga sobre
más responsabilidad de la que debería. Es muy cómodo poder mirar en el prototipo de una función si va a leer el valor de un parámetro o tan sólo escribir a él, por ejemplo en el caso de una función que coge una lista y la rellena para saber si la va a vaciar al principio o simplemente va a añadirle nuevos elementos al final, y la capacidad de hacer esta distinción se perdería para todos los tipos no triviales. También plantea preguntas difíciles sobre el tiempo de vida de un objeto. Si
significa que únicamente se va a escribir a sus bytes, comportándose en la práctica como un constructor, ¿significa eso que el lenguaje tiene que tener la posibilidad de crear variables no construidas? Las ramificaciones de esto son infinitas y peligrosísimas. ¿Qué significa destruir una variable que no ha sido construida? ¿Qué significa construir una variable dos veces? ¿Qué significa construir una variable sólo en una de las ramas de un
? Es una dirección compleja para el diseño de un lenguaje, y aunque personalmente no he experimentado en esa dirección, no parece que el resultado vaya a ser muy ergonómico para el usuario final.
Los parámetros declarativos parecen una idea muy atractiva a simple vista, pero analizados en profundidad plantean preguntas muy difíciles. También son una muy buena muestra de qué sucede cuando se razona tomando
como la vara de medir de todas las cosas. Los tipos triviales tienen propiedades muy convenientes que por desgracia no son universalmente aplicables, y es fácil caer en fallos de diseño por imaginar un modelo funcionando correctamente con tipos como
sin caer en todas las preguntas que surgen al intentar generalizarlo. En la dicotomía que plantea este texto, una solución posible para la primera opción puede ser tratar el assignment como una operación mágica en la que el compilador confía en el programador para que haga lo que quiera, y para todas las demás funciones obligar a que a un parámetro
haya que asignarle un valor o pasarlo a otra función como parámetro
. Posiblemente pueda resultar algo limitante en algunos contextos, pero tal vez algunos lenguajes puedan considerarlo un compromiso razonable. La segunda opción parece difícil de perseguir, y seguramente la exploración de una solución podría ser un texto en sí mismo.