¿Cuándo se utiliza “paso por referencia” y cuándo “paso por valor”?

Publicado por

¿Qué entendemos por “pasar una variable”? Básicamente es entregarle a una función, al momento de ser llamada, alguna variable ya existente. Si queremos que una función acepte valores externos a ella, es necesario que en su declaración coloquemos parámetros. Los parámetros serán los contenedores donde habremos de colocar las variables con las que nuestra función realizará su trabajo.

Comencemos a tratar este tema utilizando un poco de código escrito en Java. Varias han sido las ocasiones donde nos han dicho que los objetos que se mandan a una función o método se pasan por referencia; si esto fuera cierto, ¿podría alguien explicar por qué el siguiente ejemplo no realiza el cambio de nombre que queremos?

public class PasoDeVariable {
  public static void main(String[] args) {
    Persona vecino = new Persona("Hugo");
    System.out.println("-> " + vecino.nombre);

    cambiarNombre(vecino);
    System.out.println("-> " + vecino.nombre);
  }

  public static void cambiarNombre(Persona p) {
    p = new Persona("Miguel");
  }
}

class Persona {
  public String nombre;
  public Persona(String nombre) {
    this.nombre = nombre;
  }
}

// SALIDA
// -> Hugo
// -> Hugo

En el mundo de la programación existen dos formas muy famosas de pasar variables a alguna función: una se conoce como paso por valor y la otra es conocida como paso por referencia. De acuerdo a nuestras clases, la primera forma nos dice que nuestra función recibirá una copia de la variable que pasemos y, cualquier modificación que realicemos, solo afectará a dicha copia. Por otro lado, en la segunda forma se nos lleva a entregar prácticamente la variable original, es decir, si realizamos algún cambio en el parámetro de nuestra función, esto equivaldría a estar actuando directamente sobre la variable original.

Aquí se representa gráficamente lo que hasta el momento sabemos de ambas formas.

Lo anteriormente dicho luce muy bien y tal parece que no debería existir razón alguna para que nos lleguemos a confundir pero… ¿es así de sencillo? Si lo fuera, ¿por qué siguen ocurriendo confusiones respecto a ambos términos? ¿Por qué el “paso por referencia” en lenguajes como Java pareciera no estar funcionando? Es momento de otorgar respuestas. Para lograrlo, habrá que volver a definir algunos conceptos.


Definamos las referencias

El paso por referencia es el término que más controversia genera en el mundo de la computación; por tanto, si queremos utilizar adecuadamente dicha sentencia habrá que definir primero la palabra: referencia.

Una referencia es, en un sentido amplio, un valor que nos permite acceder indirectamente a un dato en particular dentro de un programa; dicho dato podría encontrarse, por ejemplo, en la memoria principal de una computadora. Poniéndolo en palabras más simples, si algo se refiere a un dato, ese algo es considerado una referencia.

En base a lo anterior, ¿cómo definirías ahora un paso por referencia? Muy sencillo, si le otorgas a una función una referencia, esta forma de paso de variable es, en toda regla, un paso por referencia. Aún así, cabe mencionar que los diseñadores de lenguajes de programación no nos la pondrán tan fácil y lograrán, tal vez sin ser su verdadera intención, complicarnos la vida.

Las referencias toman diferentes formas y pueden conocerse de distinta manera en un lenguaje de programación o en otro. Saber identificar referencias se convertirá en una habilidad muy importante que nos permitirá hacer un correcto uso de ellas (y de paso ahorrarnos unas horas de debug). No hay forma de perderse, solo hay que tener siempre presente la idea principal que ya hemos comentado. Pasemos a ver algunas implementaciones de referencias.


Distintos tipos de referencias

Como te comenté, las referencias pueden encontrarse de varias formas, todo depende de la creatividad de la persona que diseñó el lenguaje de programación que estés utilizando; además de esta implementación, también debe existir una manera de usar dicha referencia a fin de acceder al dato que queremos. Para ilustrar lo que acabo de decir, me apoyaré en 3 de los lenguajes de programación más famosos que existen y de los cuales, seguro, haz llegado a utilizar al menos uno; hablamos de C, C++ y Java.

Lenguaje C

En este lenguaje existe una implementación muy particular de una referencia: el apuntador. Por definición, un apuntador es un tipo especial de variable que almacena una dirección de memoria. ¿A quién pertenece esta dirección de memoria? Pues a algo que esté residiendo en memoria, por ejemplo una variable de tipo entero, tal vez un arreglo o una estructura.

Teniendo ya la implementación, resta saber la forma de acceder al dato haciendo uso del apuntador; para esto, C integra un operador conocido como “de desreferencia” * . Al utilizar dicho operador, logramos acceder al valor al que el apuntador se refiere.

// Ejemplo de un apuntador
int num = 5;
int *ptr_num = #

printf("valor: %d\n", *ptr_num);

// Salida
// valor: 5

Lenguaje C++

Este otro lenguaje es muy parecido a C y, de hecho, también incluye a los apuntadores en su repertorio, ¿pero qué crees? C++ cuenta con otra implementación de referencia que, para nuestra “mala suerte”, lleva exactamente el mismo nombre: referencia.

La referencia en C++ es una característica específica de este lenguaje y que tiene un comportamiento muy particular; lo que se logra con esto es darle un alias a una variable que ya se encuentra en existencia para, de esta forma, lograr realizar cambios sobre dicha variable por medio de ese alias. En términos más sencillos, solo es darle otro nombre a la misma variable.

// Ejemplo de una referencia de C++
int jose = 5;
int &pepe = jose;

// Cambiamos el valor de "jose" utilizando su alias
pepe = 10;

// Comprobamos que el valor original ha sido modificado
std::cout << "Valor: " << jose << std::endl;

// Salida
// Valor: 10

Puedes apreciar que, a diferencia de los apuntadores, aquí no es necesario realizar algo en particular sobre la referencia (o alias) para lograr acceder al valor original, dicha referencia puede utilizarse como una variable común y corriente. No obstante, nunca debes perder de vista que estas referencias son diferentes a los apuntadores. En las siguientes líneas de código te muestro las principales diferencias entres ambos conceptos:

Primera diferencia:

// - Las referencias no pueden almacenar un valor nulo -
int *ptr = NULL;
int &reference = NULL; // ¡ERROR!

Segunda diferencia:

// - Las referencias deben inicializarse al momento en que se crean -
int i = 4;

int &ref_i = i; // "ref_i" es el alias de la variable "i"
int &ref_j; // ¡ERROR! A "ref_j" se le debe asignar alguna variable

// - Los apuntadores pueden ser inicializados posteriormente -
int *ptr_i; // Declaramos al apuntador
ptr_i = &i; // Ahora lo hacemos que apunte a la variable "i"

Tercera diferencia:

// - Las referencias no pueden reasignarse a otra variable -
int x = 1;
int y = 2;

int &ref_n = x; // "ref_n" es un alias de "x"
ref_n = y; // ¡ERROR! "ref_n" está unido de por vida a "x"

// - Los apuntadores pueden cambiar de valor en cualquier momento -
int *ptr_n = &x; // "ptr_n" apunta a la variable "x"
ptr_n = &y; // Ahora "ptr_n" apunta a la variable "y"

Lenguaje Java

¿Qué podría ser peor que tener un lenguaje de programación que utiliza el término referencia con un significado distinto? Fácil, tener dos lenguajes que utilizan el término referencia con significados totalmente distintos. Java no quiso quedarse atrás y decidió crear sus propias referencias.

En Java, los manejos explícitos de memoria no existen, él es quien se encarga de realizar todo el trabajo sucio por ti (dejemos a los programadores preocuparse de cosas más importantes). Cuando tú instancias objetos en este lenguaje, dichos objetos se crean en algún lugar de la memoria; ellos no son algo que puedas tomar y guardarlo en una variable, necesitas de una entidad que te permita comunicarte con dicho objeto para poder trabajar sobre él. Esta entidad es precisamente lo que se conoce como referencia en Java.

Aunque las referencias de Java son totalmente diferentes de sus homónimas en C++, sí que guardan cierta similitud con los apuntadores. No obstante, veamos dos de las diferencias más importantes entre apuntadores y referencias de Java:

  1. No hay aritmética de apuntadores
    No es posible manipular el valor de una referencia de Java, es decir, no se le podrá sumar ni restar valor alguno a dicha referencia a fin de acceder a una región de memoria cercana a la que tenemos originalmente.
  2. Las referencias tienen tipado fuerte
    En Java no es posible que sus referencias se comporten como algo que no son. Por ejemplo, a una referencia de tipo Object solo se le podría realizar un cast a String si el objeto referido es efectivamente un String.

// Ilustración del punto 2
void casting(Object o) {
  String s = (String) o; // Válido solo si "o instanceof String == true"
}

Para obtener la referencia de un objeto, Java utiliza el operador new; una vez obtenida, la podemos almacenar en una variable y, a partir de ese momento, podemos utilizar a dicha variable como si prácticamente fuera el objeto que se ha creado en memoria.

class Objeto {
  public int valor;
}

Objeto o = new Objeto();
o.valor = 4;

Como te puedes dar cuenta, un apuntador, una referencia de C++ y una referencia de Java son, en esencia, la misma cosa. Los tres son meras implementaciones del concepto de referencia que definimos en un principio, es decir, son valores que se refieren a otro valor. Cada implementación tiene su propia particularidad, sus ventajas y desventajas (siendo la desventaja más importante el tener como nombre un término totalmente genérico).

Si tú pasas cualquiera de estas referencias a una función, no puedes estar equivocado, es un paso por referencia. Sin embargo, un último reto nos espera y radica en reconocer que, dependiendo del tipo de referencia que se trate, esta también podría ser pasada por valor o por referencia. En la siguiente sección trataremos este dilema.


Pasar una referencia por valor o por referencia

Echemos un vistazo a esta función tan famosa que permite intercambiar el valor de los dos parámetros que recibe:

// Esta primera versión puede implementarse en C o C++
void intercambio(int *a, int *b) {
  int temp = *a;
  *a = *b;
  *b = *temp;
}

// Esta segunda versión solo puede implementarse en C++
void intercambio(int &a, int &b) {
  int temp = a;
  a = b;
  b = temp;
}

Ambas versiones de la función realizan la misma tarea y ambas realizan paso por referencia pero ¿ambas funciones están recibiendo el mismo tipo de parámetros? No realmente… Aunque al finalizar la función tendremos a nuestras variables originales con sus valores intercambiados, la verdad es que la información que recibimos en cada función es diferente. En la primera versión, nuestros parámetros son apuntadores y en la segunda son referencias de C++.

¿Recuerdas lo que dijimos de que cada implementación de referencia tiene su propia particularidad? Este es un excelente momento para tomar eso en cuenta. De acuerdo a lo que ya sabemos sobre apuntadores y referencias, puedes ver que en la primera función lo que recibimos son direcciones de memoria y, en la segunda, es como si estuviéramos recibiendo la misma variable original.

De acuerdo a la primera definición que teníamos de “paso por referencia” al inicio de esta nota, este modo significa recibir la variable original como parámetro. Si tomamos esa idea como base, puedes ver que nuestra primera versión de la función intercambio() no puede continuar considerándose un paso por referencia, mientras que la segunda versión sí. ¿Quiere esto decir que todo lo que he leído hasta el momento ha sido pura pérdida de tiempo? Absolutamente no, al contrario, ¡haz encontrado el problema!

Resulta que con la idea que todos teníamos sobre el paso por referencia, es como llegaron a surgir todos los problemas y confusiones que aquejan a varios programadores. En ocasiones, mantener dicha idea nos lleva a generar “excepciones” en nuestras interpretaciones para tratar de evitar caer en alguna contradicción. Un ejemplo de esto es precisamente la función de intercambio() escrita en C, aquí podríamos llegar al punto de decir que estamos simulando un paso por referencia, todo con el afán de acercarnos lo más posible al hecho de que estamos recibiendo la variable original (aunque en realidad no sea así).

Si ahora volvemos a la definición que hemos trabajado con tanto empeño en esta nota, verás que ambas versiones de la función ya descrita caen, sin ningún problema, en el modo de “paso por referencia”. ¿Qué es lo que falta? Conocer el funcionamiento de la implementación de la referencia que estemos utilizando. En nuestro ejemplo anterior debemos conocer cómo funcionan los apuntadores para el primer caso y, para el segundo, debemos de saber cómo funcionan las referencias de C++.

Siendo las referencias de C++ muy sencillas de utilizar, pasemos a un nuevo ejemplo donde hablemos sobre los apuntadores; para esto, utilicemos una función que crea un nuevo nodo para una lista enlazada:

typedef struct _Nodo {
  int dato;
  struct _Nodo *siguiente;
} Nodo;

void crearNodo(Nodo* n, int d) {
  n = malloc(sizeof(Nodo));
  n->dato = d;
  n->siguiente = NULL;
}

// Probamos la función
Nodo *nuevo = NULL;
crearNodo(nuevo, 4);

if (nuevo == NULL) {
  printf("No se creo el nodo :(\n");
}
else {
  printf("Nodo creado con exito :D\n");
}

// SALIDA
// No se creo el nodo :(

Tristemente no fue posible crear un nuevo nodo, ¿por qué ha pasado esto? Bueno, resulta que nuestra referencia, o más específicamente, nuestro apuntador, fue pasado por valor a la función crearNodo(). Esto quiere decir exactamente lo que estás pensando, recibimos una copia de la dirección de memoria y esta se almacena en el parámetro con nombre n . En el interior de nuestra función, desechamos dicha copia y le otorgamos a n un nuevo valor por medio de malloc() . Cuando volvemos al código que llamó a la función, podemos notar que el cambio realizado se ha perdido, nuestro apuntador nuevo continúa teniendo el valor NULL .

¿Puedes ver lo que lo anterior implica? Veamos esta otra función donde ahora queremos cambiar solo el dato de un nodo:

void cambiarDato(Nodo* n, int d) {
  n->dato = d;
}

Nodo nd = {5, NULL};
printf("Dato inicial: %d\n", nd.dato);

cambiarDato(&nd, 8);
printf("Dato nuevo: %d\n", nd.dato);

// SALIDA
// Dato inicial: 5
// Dato nuevo: 8

Antes de continuar, ¿podrías decir si es un “paso por valor” o “paso por referencia” lo que estamos realizando en la función cambiarDato()? Si utilizas todo lo que hemos visto hasta ahora, deberías llegar a la conclusión de que se están utilizando ambos. ¿Cómo es eso? Bueno, si hablas del nodo que se está pasando, este está siendo pasado por referencia, la referencia en cuestión es la dirección de memoria del nodo. Ahora, si hablamos de la referencia misma, esta está siendo pasada por valor, se genera una copia de la dirección de memoria y se almacena en el parámetro n de la función.

Ahora sí, ¿por qué se realizó el cambio que buscábamos? Se debe a que no hicimos cambio alguno en el valor del apuntador que tenemos como parámetro, la asignación fue realizada sobre el campo de la estructura a la que nos estamos refiriendo con ese apuntador. En otras palabras, no cambiamos la dirección que se refiere a la estructura, cambiamos el contenido de la estructura misma.

Volviendo al código que aparece al inicio de esta nota, seguro ya puedes darte una idea de por qué no está realizando lo que se quiere. La respuesta tiene mucho que ver con lo que acabamos de discutir, por tanto, ¿qué te parece si te lo dejo como ejercicio? Una vez que lo hayas resuelto, puedes pasar al link que dejaré al final y verificar tu respuesta. Si te gustan los retos, puedes tratar de crear un método intercambio() en Java (de igual manera te dejaré un link al final con una aparente solución).


En conclusión

En un principio puede parecer poca cosa pero, en esta ocasión, te has dado cuenta cuán importante es conocer la definición de algún término dependiendo del contexto en el que este se encuentre. A diferencia de aquellos con experiencia en los lenguajes citados en esta nota, para las personas que recién comienzan a aprender, todo esto resulta muy confuso al ser común que se utilicen los mismos términos para cosas que tienen una función completamente diferente.

Casos como este aparecerán en multitud de lugares y por eso es importante que investigues bien los significados de acuerdo al área donde te encuentres. Leer la documentación es el primer paso a dar frente a cualquier duda, de ahí en más solo queda preguntar o experimentar por tu propia cuenta.

Te felicito y agradezco si lograste leer completamente hasta aquí. Espero esta nota haya sido de tu agrado y que haya cumplido su propósito de aclarar ese problema tan frecuente al que muchos nos enfrentamos cuando llegamos a conocer esos dos modos de pasar una variable. Cualquier comentario o sugerencia puedes colocarla directamente en los comentarios. Nos vemos en una siguiente entrega, hasta entonces.


Repositorio de código

https://github.com/codingornot/Paso-por-referencia-y-paso-por-valor


Referencias

Sintes, T. (2000, Mayo 26). Does Java pass by reference or pass by value? Recuperado de http://www.javaworld.com/article/2077424/learn-java/does-java-pass-by-reference-or-pass-by-value.html

Java passes by value. (2014, Noviembre 13). Recuperado de http://wiki.c2.com/?JavaPassesByValue

Pass by reference. Recuperado de http://clc-wiki.net/wiki/C_language:Terms:Pass_by_reference

Reddit. Recuperado de https://www.reddit.com/r/programming/comments/yitzw/is_c_pass_by_value_or_reference/

Jacobson, S. An overview of C++ references. Recuperado de http://www-cs-students.stanford.edu/~sjac/c-to-cpp-info/references

Pass by reference vs. pass by value. Recuperado de https://www.cs.fsu.edu/~myers/c++/notes/references.html

Flanagan, D. (2005). Java in a nutshell. O’Reilly Media.

Ritchie, D. M. (1988). The C Programming Language. Prentice Hall.

Gosling, J. (2005). The Java Programming Language. Addison-Wesley Professional.

Deja una respuesta

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