Herramientas de usuario

Herramientas del sitio


curso-cpp:modularizacion-funciones

Funciones

Motivación

Veremos en esta lección el concepto de función. Empezaremos tratando de entender cuál es el problema o dificultad que las funciones nos pueden ayudar a aliviar, es decir, empezaremos dándonos una mínima idea de la respuesta a la pregunta de: “Funciones, ¿para qué?”.

Si bien es posible resolver cualquier problema sin usar funciones, hay 3 excelentes motivos para utilizar funciones:

  1. Para ordenar mejor el código fuente, y hacerlo más fácil de leer y entender.
  2. Para facilitar un enfoque de programación top-down, en el cuál vamos descomponiendo el problema en partes, y luego nos concentramos en cómo resolver cada parte.
  3. Para evitar tener código repetido, que es algo que debemos evitar a toda costa si queremos programar mejor y con menos chances de errores.

Ordenar el código fuente

Supongamos que tenemos el siguiente código (que iría dentro de main en un programa):

int a,b;
cout << "Ingrese el rango de numeros a explorar" << endl;
cin >> a >> b;
int sumaPrimosAlCuadrado = 0;
for (int i=a; i<= b; i++)
if (i >= 2)
{
    bool esPrimo = true;
    for (int d = 2; d*d <= i; d++)
    if (i % d == 0)
        esPrimo = false;
    if (esPrimo)
        sumaPrimosAlCuadrado += i*i;
}
cout << "La primera suma pedida es:" << sumaPrimosAlCuadrado << endl;
int sumaMultiplosEspeciales = 0;
for (int i=a; i<= b; i++)
{
    if ((i % 3 == 0 || i % 10 == 0) && i % 30 != 0)
        sumaMultiplosEspeciales += i;
}
cout << "La segunda suma pedida es:" << sumaMultiplosEspeciales << endl;

Es muy difícil de entender qué hace este código a simple vista, y el motivo principal es que hace muchas cosas mezcladas todas a la vez. Veremos que una función permite separar una porción de programa definida, y luego usarla cuando la necesitemos. Es bueno comparar el tener que leer y entender el programa anterior, con leer algo como lo siguiente:

int a,b;
leerRangoAExplorar(a,b);
mostrarResultados(sumaDePrimosAlCuadradoEnRango(a,b), sumaDeMultiplosEspecialesEnRango(a,b));

Aquí, leerRangoAExplorar(a,b); corresponderá al siguiente fragmento del código anterior:

cout << "Ingrese el rango de numeros a explorar" << endl;
cin >> a >> b;

Mientras que sumaDePrimosAlCuadradoEnRango(a,b) representa:

int sumaPrimosAlCuadrado = 0;
for (int i=a; i<= b; i++)
if (i >= 2)
{
    bool esPrimo = true;
    for (int d = 2; d*d <= i; d++)
    if (i % d == 0)
        esPrimo = false;
    if (esPrimo)
        sumaPrimosAlCuadrado += i*i;
}

sumaDeMultiplosEspecialesEnRango(a,b) corresponde al:

int sumaMultiplosEspeciales = 0;
for (int i=a; i<= b; i++)
{
    if ((i % 3 == 0 || i % 10 == 0) && i % 30 != 0)
        sumaMultiplosEspeciales += i;
}

Y el mostrarResultados corresponde al:

cout << "La primera suma pedida es:" << sumaPrimosAlCuadrado << endl;
cout << "La segunda suma pedida es:" << sumaMultiplosEspeciales << endl;

Separando estas operaciones que son independientes entre sí, y dándoles un nombre claro, el código es mucho más fácil de entender, ya que podemos analizar cada parte independientemente por un lado, y por otro lado, su combinación para formar el programa completo. leerRangoAExplorar , sumaDePrimosAlCuadradoEnRango, sumaDeMultiplosEspecialesEnRango y mostrarResultados son ejemplos de funciones.

Facilitar un enfoque top-down

Esta ventaja está estrechamente relacionada con la anterior. Supongamos que nos dieran la siguiente consigna:

“Crear un programa que lea dos números a y b, que indican un rango de números (inclusive), y calcule y muestre en la pantalla dos valores: La suma de los cuadrados de todos los números primos entre a y b, y además, la suma de todos los números entre a y b que son múltiplos de 3 y de 10, pero no de 30.”

El programa anterior (el que tenía todo junto) sería un ejemplo de resolución de esta tarea. Ahora bien, escribir y pensar todo ese código junto en un solo paso, a partir de esta descripción, es muy complicado. Pero podríamos planear descomponer este problema en tareas más chicas, y escribir nuestra solución suponiendo que tenemos resueltas esas tareas.

Por ejemplo, en este caso podríamos identificar que tenemos que hacer 4 cosas:

  1. Leer a y b de la entrada
  2. Calcular la suma de los cuadrados de los primos pedidos
  3. Calcular la suma de los números que son “múltiplos especiales” (explicado en la consigna)
  4. Escribir los resultados a la salida

Identificamos entonces que para 1), necesitaremos dos variables a,b en las cuales guardaremos los números leídos. Podemos denominar leerRangoAExplorar(a,b); al proceso de leer esas variables: Sin preocuparnos por ahora sobre cómo lo haremos. Eso lo dejamos para después.

Para realizar los cálculos de 2) y 3), necesitaremos los valores a y b, que obtuvimos en 1). Llamaremos (de vuelta, sin preocuparnos todavía por cómo lograremos hacer los cálculos) sumaDePrimosAlCuadradoEnRango(a,b) al resultado de hacer los cálculos que indica el paso 2), y llamaremos sumaDeMultiplosEspecialesEnRango(a,b) al resultado del paso 3).

Finalmente, llamaremos mostrarResultados al proceso del paso 4), que usa los resultados obtenidos en los pasos 2 y 3. Con estas ideas, podemos planear un esqueleto de nuestro programa, que quedaría más o menos así:

int a,b;
leerRangoAExplorar(a,b);
mostrarResultados(sumaDePrimosAlCuadradoEnRango(a,b), sumaDeMultiplosEspecialesEnRango(a,b));

Ahora que tenemos el esqueleto del programa listo, podemos concentrarnos en detalle en ver cómo resolvemos cada una de esas 4 partes. Usar funciones nos permitirá escribir este esqueleto directamente en el main, y luego aclarar en funciones separadas cómo realizar cada una de las 4 partes.

Evitar código repetido

Llamamos código repetido a un conjunto de operaciones completamente análogas, que aparece repetido en el programa más de una vez. Esto es algo que queremos evitar a toda costa porque es muy propenso a errores.

Una ejemplo podría ser, si tenemos un programa que tiene que analizar si un número dado es primo, luego realizar un montón de cálculos y operaciones, y al final del programa antes de terminar debe analizar si otro número es primo. Si bien se analizan dos números distintos, la serie de operaciones que hacemos es la misma en los dos casos, y solamente cambia el número (o la variable donde está guardado). Esto nos llevaría a tener dos fragmentos del programa (por ejemplo, dos for con cálculos) esencialmente iguales, uno para cada número. Esto lleva fácilmente a errores, porque si en algún momento queremos cambiar algo de este código (o corregir un error encontrado) tenemos que cambiarlo en los dos lugares, y es muy fácil olvidarse de cambiar uno, o equivocarse en uno de los cambios.

Mediante funciones, podremos escribir el código para determinar si un número es primo una sola vez, y luego reutilizar ese código todas las veces que queramos, sin necesidad de escribir todo el código de nuevo.

La idea de función

Una función en C++1) será un fragmento de programa bien definido, que puede utilizarse como parte de otros programas o funciones.

La sintaxis para definir una función es la siguiente:

tipo_de_la_respuesta nombreDeLaFuncion(lista_de_parametros)
{
    // Cuerpo de la funcion, con las instrucciones correspondientes a la misma
    return resultado; // Con return se termina la función y se indica el resultado final
}

Esto debe escribirse antes del main, y no adentro. Eso es porque main es una función como las demás, y no se permite en C++ escribir una función dentro de otra. La particularidad que tiene la función main es que es allí donde comienza a ejecutarse el programa: la computadora comienza a leer instrucciones por el main.

Una función, al ser un fragmento de programa, puede realizar cálculos y tareas, y puede eventualmente producir un resultado. Ese resultado se llama el valor de retorno de la función, y se dice que la función lo devuelve al usar la instrucción return. En el código anterior, la parte de tipo_de_la_respuesta se usa para indicar el tipo que tendrá el resultado de la función.

Veamos un ejemplo de función con lista_de_parametros vacía, lo cual es válido en C++:

int leerUnNumeroYElevarloAlCuadrado()
{
    int x;
    cin >> x;
    return x*x;
}

Este código corresponde a un fragmento de programa, que lee con cin un número x, y devuelve con return el valor x*x, es decir el número al cuadrado: Si se lee 3, se devuelve 9, si se lee 5 se devuelve 25, si se lee -2 se devuelve 4, etc. Cuando se ejecuta una instrucción return, se devuelve el valor indicado y la función termina inmediatamente, sin importar que pudiera haber más instrucciones además del return.

Como lo que devuelve es un entero, hemos colocado int justo al comienzo, antes del nombre de la función. Los paréntesis luego del nombre de la función son obligatorios siempre que escribamos una función, incluso cuando dentro de ellos no pongamos nada, como en el ejemplo.

Si en nuestro programa colocamos este código antes del main, podremos utilizar esta función en el programa principal: para ello basta con escribir la instrucción leerUnNumeroYElevarloAlCuadrado();, y eso automáticamente ejecutará todo el código correspondiente a esa función. Nuevamente, al utilizar (también denominado llamar o invocar) una función, los paréntesis son obligatorios.

Veamos a continuación un ejemplo de programa completo que usa esa función:

#include <iostream>
 
using namespace std;
 
int leerUnNumeroYElevarloAlCuadrado()
{
    int x;
    cin >> x;
    return x*x;
}
 
int main()
{
    int a = leerUnNumeroYElevarloAlCuadrado();
    int b = leerUnNumeroYElevarloAlCuadrado();
    int c = leerUnNumeroYElevarloAlCuadrado();
    cout << "El gran total es: " << a+b+c << endl;
}

Este programa lee tres números, y al final muestra el gran total: La suma de los cuadrados de los números leídos. Notar que la operación de leer un número con cin y elevarlo al cuadrado se realiza 3 veces, porque 3 veces escribimos leerUnNumeroYElevarloAlCuadrado() en el programa principal: Pero una sola vez tuvimos que escribir las instrucciones completas para realizar esa tarea, al definir la función antes del main. Luego podemos usarla libremente como si fuera una operación más.

De este programa podemos destacar que cuando tenemos una función que devuelve un resultado, al llamar a la función podemos directamente escribir la llamada y usar el resultado en una expresión, como si fuera directamente el valor. Es decir, cuando ponemos leerUnNumeroYElevarloAlCuadrado(), podemos pensar para nuestro razonamiento que eso se va a reemplazar directamente por el resultado final de los cálculos de la función.

Así, si al ejecutar el programa anterior ingresáramos los valores 3, 1, y 10, sería como si en el main se ejecutase lo siguiente:

int main()
{
    int a = 9;
    int b = 1;
    int c = 100;
    cout << "El gran total es: " << a+b+c << endl;
}

Obteniendo el resultado 110. A modo de ejemplo, damos una versión distinta del programa que calcula la suma del triple de cada número al cuadrado, para que quede claro que las llamadas a funciones se pueden usar en el medio de expresiones más complejas si así nos conviene (damos solo el main, pues la función es igual que antes):

int main()
{
    int a = 3 * leerUnNumeroYElevarloAlCuadrado();
    int b = 3 * leerUnNumeroYElevarloAlCuadrado();
    int c = 3 * leerUnNumeroYElevarloAlCuadrado();
    cout << "El gran total es: " << a+b+c << endl;
}

Incluso sería válido (aunque es más difícil de leer) escribir el programa original con todas las llamadas en la misma línea:

int main()
{
    cout << "El gran total es: " << leerUnNumeroYElevarloAlCuadrado() + 
                                    leerUnNumeroYElevarloAlCuadrado() + 
                                    leerUnNumeroYElevarloAlCuadrado() << endl;
}

Este último ejemplo muestra una característica importante de las funciones: Cada vez que se escribe una llamada a función en el código, se ejecutan las instrucciones de la función: Si se escribe 3 veces, se ejecutan tres veces. En nuestro ejemplo, eso quiere decir que el fragmento anterior no lee un número y luego lo “triplica” al sumarlo consigo mismo 3 veces, sino que lee 3 números distintos, porque lee uno nuevo en cada llamada.

Si queremos reutilizar el valor que se obtuvo al ejecutar una función sin volver a ejecutarla, conviene guardar el resultado en una variable, como hicimos en los primeros ejemplos, donde teníamos algo como int a = leerUnNumeroYElevarloAlCuadrado();.

Parámetros

No siempre queremos que una función haga exactamente lo mismo cada vez que se usa. A veces, queremos que haga casi lo mismo, pero cambiando algún dato entre usos. Por ejemplo, podríamos querer una función que eleve un número al cuadrado, es decir, que permita calcular x*x si ya tenemos un entero x. Así, cuando usamos la función con 3, queremos que devuelva 3*3 == 9, y cuando la usamos con -4 queremos que devuelva (-4)*(-4) == 16.

En el ejemplo anterior la función no hace siempre lo mismo, porque a veces hace 3*3 y a veces (-4)*(-4), pero más allá del número que vamos a elevar, las operaciones que hace la función son siempre las mismas, y solo cambia este dato inicial. A ese dato que cambia, lo llamamos en programación un parámetro de la función. Una función puede tener 1 o más parámetros, o incluso cero: Las funciones que vimos antes tenían cero parámetros. La función de elevar al cuadrado tendría un único parámetro: El número entero que vamos a querer elevar.

Los parámetros que tendrá una función se indican al escribir su código, entre paréntesis, luego del nombre: se debe dar una lista separada por comas, en la cual se indique el tipo y el nombre de cada parámetro. Es por eso que en los ejemplos anteriores necesitamos siempre un par de paréntesis () vacíos: las funciones que estábamos utilizando tenían cero parámetros.

Veamos a continuación el ejemplo de la función para elevar un entero al cuadrado:

int alCuadrado(int x)
{
    return x*x;
}

Esta función es muy simple, pues lo único que hace es devolver x*x. Notemos que x es el único parámetro de esta función: entre paréntesis hemos indicado su tipo, int, y le hemos dado el nombre x, lo cual permite usar este parámetro en el código de la función.

Cuando una función tiene parámetros, al usarla hay que aclarar qué valores tomar para esos parámetros. En nuestro ejemplo de función que eleva al cuadrado un número, no podemos en el programa simplemente usar alCuadrado() como en los ejemplos anteriores: ¿Cuál sería el resultado si hiciéramos eso? ¿Qué número se estaría elevando al cuadrado? La función no puede saberlo si no se lo indicamos. Por este motivo, cuando se usa una función con parámetros hay que indicar entre paréntesis los valores que queremos usar para esos parámetros, en el mismo orden en que se dieron al escribir el código de la función.

Veamos un ejemplo completo de programa que usa la función alCuadrado:

#include <iostream>
 
using namespace std;
 
int alCuadrado(int x)
{
    return x*x;
}
 
int main()
{
    int num;
    cout << "Ingrese un numero" << endl;
    cin >> num;
    cout << "El numero " << num << " al cuadrado es " << alCuadrado(num) << endl;
    cout << "De paso, te cuento que 7 al cuadrado es " << alCuadrado(2+5) << endl;
    return 0;
}

El ejemplo muestra que la llamada a una función (es decir, usarla) cuenta como una “operación”, igual que la suma o la resta, y puede usarse en expresiones más complicadas (por ejemplo como parte de una cuenta y operaciones aritméticas).

Funciones con varios parámetros

Una función puede necesitar varios datos para realizar su tarea. De ser así, se trabaja de idéntica manera pero separando los parámetros con comas, y dándolos siempre en el mismo orden. Por ejemplo, a continuación se muestra un ejemplo de código que usa una función que pega dos palabras usando un guión entre ellas:

#include <iostream>
 
using namespace std;
 
string pegarConGuion(string palabra1, string palabra2)
{
    return palabra1 + "-" + palabra2;
}
 
int main()
{
    string a,b;
    cout << "Ingrese dos palabras" << endl;
    cin >> a >> b;
    cout << pegarConGuion(a,b) << endl;
    cout << pegarConGuion("super", "sonico") << endl;
    return 0;
}

Alentamos al lector a que lea este código y prediga qué es lo que va a mostrar por pantalla, y luego verifique que efectivamente así sea.

Procedimientos

Veremos ahora ejemplos de funciones que no devuelven nada, lo que a veces (sobre todo en otros lenguajes) se denomina procedimiento o subrutina (En C++, lo usual es llamar a todos función, aunque no devuelvan nada).

¿Por qué podríamos querer que una función no devuelva nada? Porque podría importarnos solamente lo que la función hace, es decir, las instrucciones que ejecuta, sin necesidad de que nos devuelva un valor final. Un ejemplo sencillo de esto podría ser alguna función que escriba en pantalla:

void mostrarConEspacios(string palabra)
{
    for (int i=0;i<int(palabra.size());i++)
    {
        if (i > 0)
            cout << " ";
        cout << palabra[i];
    }
}

Esta función recibe una palabra, y la escribe en pantalla pero con un espacio insertado entre cada par de letras consecutivas. En este caso, no hay nada que devolver: Al que llama no le importa obtener ningún resultado, sino simplemente lograr que se escriba a pantalla lo que queremos. Una vez que la función hace lo que queríamos (en este caso, imprimir a pantalla), el que la usa no espera ningún resultado adicional. Es por eso que por ejemplo no pusimos return en esta función, pues no hay nada que devolver.

Cuando una función no devuelve nada, se indica void como su tipo de retorno. void significa “vacío” en inglés, y es un tipo muy especial que se usa en el valor de retorno de las funciones para indicar que no devuelven nada.

En una función que devuelve void (que es lo mismo que decir que no devuelve nada), no es posible utilizar return x;, pues no se puede devolver nada. Sin embargo, está permitido utilizar return; a secas, sin indicar ningún valor de retorno: Esto tiene el efecto de terminar inmediatamente la ejecución de la función, en el momento en que se ejecuta el return.

Pasaje por copia y por referencia

Supongamos que escribimos la siguiente función, con la idea de que aumente en uno la variable indicada:

void incrementar(int x)
{
    x = x + 1;
}

Con lo que vimos hasta ahora, podría parecer que esta función hará lo que queremos. Podemos intentar utilizarla en un programa:

#include <iostream>
 
using namespace std;
 
void incrementar(int x)
{
    x = x + 1;
}
 
int main()
{
    int x = 20;
    cout << x << endl;
    incrementar(x);
    cout << x << endl;
    incrementar(x);
    cout << x << endl;
    return 0;
}

Querríamos que este programa muestre 20, 21 y 22, pues utiliza nuestra función para ir aumentando la variable. Sin embargo si lo ejecutamos, veremos por pantalla 20, 20 y 20: No se ha incrementado nada. ¿Por qué resulta ser así?

Para ayudar a entender esto agregaremos al programa un par de instrucciones con cout dentro de la función, para ver si se incrementa o no.

#include <iostream>
 
using namespace std;
 
void incrementar(int x)
{
    cout << "Recibo x con " << x << endl;
    x = x + 1;
    cout << "Al terminar x tiene " << x << endl;
}
 
int main()
{
    int x = 20;
    cout << x << endl;
    incrementar(x);
    cout << x << endl;
    incrementar(x);
    cout << x << endl;
    return 0;
}

Este programa muestra por pantalla:

20
Recibo x con 20
Al terminar x tiene 21
20
Recibo x con 20
Al terminar x tiene 21
20

Podemos ver que dentro de la función se está produciendo el incremento, como queremos, pues se recibe 20 y luego se tiene 21. Pero este cambio no se observa fuera de la función: La llamada a la función parece no estar teniendo ningún efecto sobre el x del main.

El motivo por el que esto ocurre es que cuando se ejecuta una función, al comenzar se hace una copia de los parámetros: En la primera llamada a incrementar, El x del main vale 20. Pero la función no trabaja con el x del main: trabaja todo el tiempo con una copia de ese x. Así, la función tiene su propia copia del parámetro x, que al comenzar toma el valor 20 que tenía la variable con la cual fue llamada.

Lo que observamos es que la función incrementa esta copia, y dentro de la función siempre estamos usando la copia, pero al terminar la función esta copia deja de existir y la variable original del main queda inalterada sin cambios. Si bien en muchos casos esto es exactamente lo que queremos, para evitar que una función “nos cambie accidentalmente” nuestras variables, en este ejemplo queremos intencionalmente cambiar una variable del main. Es decir, en este ejemplo queremos intencionalmente que no se haga ninguna copia para la función, sino que se trabaje directamente con el dato original, para que todos los cambios que haga la función se hagan sobre el original.

La manera de hacer esto en C++ es agregando un ampersand & justo antes del nombre del parámetro en el momento en que escribimos el código de la función: dicho ampersand indica a C++ que no queremos trabajar con una copia, sino con la variable original directamente.

Nuestro ejemplo quedaría entonces (solamente le agregamos un & en el parámetro x):

#include <iostream>
 
using namespace std;
 
void incrementar(int &x)
{
    cout << "Recibo x con " << x << endl;
    x = x + 1;
    cout << "Al terminar x tiene " << x << endl;
}
 
int main()
{
    int x = 20;
    cout << x << endl;
    incrementar(x);
    cout << x << endl;
    incrementar(x);
    cout << x << endl;
    return 0;
}

Que produce el resultado esperado:

20
Recibo x con 20
Al terminar x tiene 21
21
Recibo x con 21
Al terminar x tiene 22
22

Notar que el ampersand se debe agregar en la declaración (donde escribimos el código) de la función, y no en la invocación (donde la usamos / llamamos).

A la forma normal de pasar los parámetros, que ocurre cuando no especificamos ningún ampersand, se la llama pasar por copia o pasar por valor, pues en la función directamente se copia el valor de los parámetros y se trabaja siempre con las copias, sin cambiar los originales.

En cambio, cuando se usa el ampersand, se dice que ese parámetro se pasa por referencia o por variable, pues en todo momento se hace referencia a la variable original, y nunca existe una copia diferente para la función. Cualquier cambio que haga la función, impactará en la variable original.

La mayoría de la veces pasaremos los parámetros por valor, y solo cuando tengamos alguna razón específica para hacerlo los pasaremos por referencia. Uno de los ejemplos más comunes es el que acabamos de ver, donde queremos modificar una variable del main como parte de las operaciones de la función.

Procedimientos + Pasaje por referencia

El uso de procedimientos es bastante usual cuando se utilizan parámetros pasados por referencia. Esto es porque uno de los usos principales de pasar un parámetro por referencia es para que la función lo pueda modificar, y entonces, si queremos que la función modifique el parámetro que le pasamos, es posible (pero no necesariamente cierto) que no necesitemos ningún resultado adicional, sino que solamente nos importe esta modificación.

Un ejemplo podría ser una función que recibe un vector<int> y duplica todos sus elementos:

#include <iostream>
#include <vector>
 
using namespace std;
 
void duplicarElementos(vector<int> &v)
{
    for (int i=0;i<int(v.size());i++)
        v[i] *= 2;
}
 
int main()
{
    vector<int> w = {1,2,3,8};
    duplicarElementos(w);
    for (int x : w)
        cout << x << endl;
    return 0;
}

Que muestra por pantalla:

2
4
6
16

Notar que si cambiamos la línea void duplicarElementos(vector<int> &v) por void duplicarElementos(vector<int> v) (quitando el &), el programa ya no hará lo qué queremos. ¿Qué mostrará? ¿Y por qué es así?

Variables locales y globales

Todas las variables que hemos utilizado hasta ahora han sido siempre variables locales. Esto significa que fueron definidas dentro de una función (main es también una función: por lo tanto, las variables que hemos definido en main son también variables locales, de la función main).

Una variable local solamente puede utilizarse dentro de la función en que fue definida, y no desde otras funciones (para eso existe la idea de usar los parámetros, para pasar información útil a una función).

Existe otro tipo de variables que pueden accederse desde cualquier lugar del programa. Estas variables se denominan variables globales. Basta declararlas en el programa directamente fuera de cualquier función para obtener una variable global. Desde ese punto del programa en adelante, se podrá utilizar esa variable, que está “compartida” entre todas las funciones del programa.

En general, utilizar demasiadas variables globales en lugar de parámetros es considerado una mala práctica en C++, y hacerlo puede llevar fácilmente a tener programas difíciles de leer. Se utilizan principalmente en competencias de programación, como forma práctica de tener accesibles datos importantes que son utilizados a lo largo de todo el programa, y evitar así tener que pasar los mismos parámetros todo el tiempo entre funciones. Existen otras técnicas para lograr esto mismo, pero son más avanzadas (por ejemplo, usar struct/class y métodos, y/o enfoques de programación orientada a objetos).

Además, existe un peligro adicional a tener en cuenta las variables globales, que es la posibilidad de ocultar una variable global con una variable local. Esto ocurre cuando tenemos una variable local con el mismo nombre que una variable global: Estas dos serán variables distintas, pero al tener el mismo nombre, no es posible utilizar ambas a la vez. Dentro de la función, solamente será posible utilizar la variable local. Por eso se dice que la variable local oculta a la global.

Por ejemplo, el siguiente código:

int mivar = 32;
 
int foo()
{
   int mivar = 12;
   mivar++;
   return 2*mivar;
}
 
int main()
{
    cout << foo() << endl;
    cout << mivar << endl;
    return 0;
}

Mostrará 26 y 32. El valor de la variable global mivar nunca es modificado, ya que dentro de foo se trabaja con la variable local del mismo nombre, que oculta a la variable global correspondiente. Por estos motivos, en general es muy mala idea usar el mismo nombre para una variable global y una local, pues podemos tener problemas y usar la variable que no queríamos sin darnos cuenta.

En inglés esto se denomina shadow: Si se activa la opción -Wshadow, el compilador nos advierte si ocultamos una variable de esta forma.

Observaciones adicionales

  • En las funciones se puede llamar (utilizar) otras funciones: esto está totalmente permitido:
    int multiplicar(int a, int b)
    {
        return a*b;
    }
 
    int elevar(int base, int exponente)
    {
        int resultado = 1;
        for (int i=0;i<exponente;i++)
            resultado = multiplicar(resultado, base);
        return resultado;
    }
 
  • Variables con el mismo nombre pero definidas en funciones distintas, representan variables diferentes (como el x del main y el x de la función en el ejemplo anterior de incrementar)

Ejemplos de implementación de funciones

  • Nuestro primer ejemplo es la función main, que ya venimos usando en todos nuestros programas: Es una función que devuelve un int, que usa la computadora para saber si hubo errores. Por convención, se debe devolver cero si todo salió bien, y por eso es buena costumbre terminar todos los programas con return 0. La función main es importante porque tiene la característica especial de que allí comienzan a ejecutarse todos nuestros programas, aunque tengan otras funciones.
  • Como ejemplo de pensamiento top-down, supongamos que debemos realizar un programa que lea una secuencia de números, y luego calcule y muestre por pantalla la suma, el máximo y el mínimo de todos estos números. Podemos programar primero que anda el main de la siguiente manera:
    int main()
    {
        vector<int> v;
        v = leerNumeros();
        imprimirResultados(suma(v), maximo(v), minimo(v));
        return 0;
    }

    Donde nos hemos ordenado y hemos logrado descomponer el problema entero en tareas más pequeñas. Luego podríamos agregarle las funciones que faltan al programa, para completarlo, dejando inalterado el mismo main que ya escribimos:

    vector<int> leerNumeros()
    {
        // instrucciones...
    }
     
    int suma(vector<int> v)
    {
        // instrucciones...
    }
     
    int maximo(vector<int> v)
    {
        // instrucciones...
    }
     
    int minimo(vector<int> v)
    {
        // instrucciones...
    }
     
    void imprimirResultados(int laSuma,int elMaximo, int elMinimo)
    {
        // instrucciones...
    }
  • El siguiente es un ejemplo con funciones que calculan áreas de figuras geométricas, que muestra como podemos reutilizar ciertas funciones dentro de otras:
    int areaParalelogramo(int base, int altura)
    {
        return base * altura;
    }
    int areaCuadrado(int lado)
    {
        return areaParalelogramo(lado, lado);
    }
    int areaTriangulo(int base, int altura) // Trabaja con enteros: Redondea hacia abajo
    {
        return areaParalelogramo(base, altura) / 2;
    }
  • El siguiente es un ejemplo de función que recibe dos variables enteras, e intercambia sus valores. ¡Notar el uso del ampersand!
    void intercambiar(int &variable1, int &variable2)
    {
        int auxiliar = variable1; // Es necesario un auxiliar: ¿Por qué?
        variable1 = variable2;
        variable2 = auxiliar;
    } 

    Similarmente, el siguiente ejemplo permite “rotar” los valores de tres variables dadas: Es decir, transforma [a,b,c] en [b,c,a]:

    void rotar3(int &a, int &b, int &c)
    {
       int auxiliar = a; // Nuevamente, ¿Por qué es necesario el auxiliar?
       a = b;
       b = c;
       c = auxiliar;
    }

Algunas funciones predefinidas

En C++, existen algunas funciones predefinidas que ya existen, y conocerlas puede simplificarnos la tarea de programar ya que nos ahorramos tener que escribirlas nosotros mismos. Mencionamos algunas a continuación:

  • max: Dados dos números, devuelve el máximo. Por ejemplo max(2,9) == 9 y max(5,3) == 5. Similarmente tenemos min para el mínimo.
  • swap: Intercambia los valores de las dos variables que se le indica. Por ejemplo si x tiene un 3, y q[i] tiene un 8, luego de hacer swap(x,q[i]) quedará q[i] con un 3 y x con un 8.
  • abs: Devuelve el valor absoluto (módulo) de un entero. Por ejemplo abs(-3) == 3, abs(0) == 0 y abs(15) == 15.

Todas estas funciones requieren utilizar #include <algorithm> para tenerlas disponibles.

Algunos errores comunes

  • Pasar a la función una cantidad de parámetros diferente de las que la función necesita, o con el tipo incorrecto. Por ejemplo si tenemos la función
    int mayor(int num1, int num2)
    {
        if (num1 > num2)
            return num1;
        else
            return num2;
    }

    serían incorrectas las siguientes llamadas:

    mayor(k,m,n) // Pasa 3 parámetros, pero la función toma solamente 2
    mayor(23, "miliwatt") // Pasa 2 parámetros, pero el segundo es una cadena y debería ser un int
  • Diseñar una función con la idea de que modifique uno de sus parámetros, pero trabajar con una copia por no utilizar el ampersand &.
  • Intentar utilizar un parámetro (con su nombre) fuera de una función: Los parámetros solamente están definidos dentro de la función, y no tiene sentido utilizarlos fuera de ella (son variables locales).
  • Utilizar una función que todavía no se definió. Se debe programar el código de una función, antes de utilizarla.

Ejercicios

Recomendamos revisar los ejercicios pasados del curso, y volver a programarlos aprovechando la idea de funciones: ¿En cuáles es apropiado utilizar funciones para estructurar mejor el programa?

Por ejemplo, en los ejercicios en los que se hablaba de números primos, se podría escribir una función que toma un int N, y devuelve un bool indicando si es primo. O por ejemplo, escribir una función sumaDeDivisores puede ser útil para escribir de forma más fácil y clara programas que buscan números perfectos.

Otros ejercicios:

  • Escribir una función string escribirEnBase(int numero, int base), que tome un número y una base (Entre 2 y 16 inclusive) y devuelva una cadena con la escritura de ese número en la base indicada.
  • Escribir una función int leerNumeroEnBase(string escritura, int base), que tome la escritura de un cierto número en la base indicada (Entre 2 y 16 inclusive) y devuelva el número en cuestión.

Puede ver aquí cómo realizar cambios de base.

Los #define

En C++, además de las funciones que ya vimos, existe un mecanismo relacionado para evitar repetir código, y es la directiva #define.

Mediante un #define, es posible definir lo que se denomina una macro, que es una regla para reemplazar textualmente un fragmento de código en el programa. Por ejemplo, supongamos que colocamos en cualquier línea de un programa, el siguiente #define:

#define declaraEInicializaEnUnValor(nombreVariable, valor) int nombreVariable = valor

Luego de haber escrito este #define, si en cualquier lugar del programa aparece un declaraEInicializaEnUnValor(x, 33), se reemplazará textualmente en ese mismo lugar por un int x = 33. Por ejemplo,el siguiente sería un programa válido:

#include <iostream>
 
using namespace std;
 
#define declaraEInicializaEnUnValor(nombreVariable, valor) int nombreVariable = valor
 
int main()
{
    declaraEInicializaEnUnValor(x, 40);
    declaraEInicializaEnUnValor(y, 100);
    cout << x + y << endl;
    return 0;
}

Que muestra por pantalla 140. Notar que luego de los reemplazos, el programa es absolutamente equivalente a si se hubiera escrito directamente:

#include <iostream>
 
using namespace std;
 
int main()
{
    int x = 40;
    int y = 100;
    cout << x + y << endl;
    return 0;
}

En general, siempre que podamos conviene utilizar funciones en lugar de #defines, y dejaremos los #defines únicamente para los casos en que no podamos hacer lo mismo con una función. Por ejemplo, en el ejemplo anterior creamos una macro que permite declarar una variable y empezar a usarla directamente, que es algo que no podríamos haber hecho con una función. A diferencia de una función, una macro hace un reemplazo totalmente mecánico de los valores indicados entre paréntesis, exactamente igual que si se hiciera copy paste mecánicamente en el código.

Algunos ejemplos de macros muy útiles

Especialmente en competencias de programación, es muy común tener un fragmento de código como el siguiente:

for (int numero = 0; numero < valorMaximo; numero++)
    // instrucciones

Donde recorremos todos los números desde 0 hasta valorMaximo - 1 inclusive. Notar que al escribir ese fragmento tenemos que escribir la variable numero tres veces, lo cual aumenta las chances de equivocarnos (especialmente, si hacemos copy + paste de otro for similar, ya que entonces es muy fácil olvidarnos de cambiar alguna de las 3 apariciones).

Otra variante muy similar sería cuando queremos recorrer todos los índices de un cierto vector:

for (int var = 0; var < int(vector.size()); var++)
    // instrucciones

En este caso se agrega la conversión con int(), que debe usarse al comparar con .size().

Para programar más fácilmente y con menor chances de errores este tipo de for comunes, podemos utilizar un #define para definir una forma compacta de indicar los elementos importantes que cambian de caso en caso, y que el resto se reemplace mecánicamente siempre igual:

#define forn(i,n) for(int i=0;i<int(n);i++)

En este caso, indicamos en el #define el nombre de la variable que vamos a declarar, y el valor tope hasta el cual vamos a iterar (iteraremos desde 0 hasta n-1 inclusive). Esto es lo único que cambia en estos ejemplos, y el resto es siempre igual.

De esta forma, una vez que tenemos este #define el primer for que mostramos nos quedaría simplemente:

forn(numero, valorMaximo)
    // instrucciones

Y el segundo quedaría:

forn(var, vector.size())
    // instrucciones

Vemos que ahora solamente hace falta especificar una vez el nombre de la variable, y todo lo demás es copiado automáticamente en forma mecánica por el #define.

Se puede consultar aquí otros ejemplos de macros más avanzadas, muy útiles para programación competitiva, además del forn ya mostrado.

Por qué es mejor usar funciones

Hemos mencionado que siempre que podamos hacer algo con funciones en lugar de #define, es conveniente hacerlo con funciones. Esto es porque las funciones son “más seguras” de utilizar. Veamos un ejemplo para ver por qué esto es así.

Supongamos que queremos tener un fragmento de código para elevar un número al cuadrado. La forma más simple de hacerlo es multiplicar al número con sí mismo, ya que multiplicar es una operación atómica disponible. Podemos entonces pensar en hacer un #define para ello:

#define cuadrado(x) x*x

De esta forma, cuando escribamos cuadrado(2), por ejemplo, se reemplaza por 2*2, que es el número al cuadrado como queremos. Sin embargo, el #define que acabamos de definir es muy peligroso de utilizar: imaginemos por ejemplo que lo usamos en la siguiente línea:

cout << cuadrado(2+3) << endl;

Esperamos obtener 25... pero la salida de este programa produce 11. ¿Por qué ha ocurrido esto?

El motivo es que, como ya hemos mencionado, los #define realizan un reemplazo mecánico y textual de los elementos que les indicamos, igual que si hiciéramos copy paste, sin entender el significado de su contenido. Por lo tanto, como el #define indica que debemos cambiar cuadrado(x) por x*x, tenemos que fijarnos quién es x, copiarlo dos veces y colocar un asterisco en el medio, textualmente, pues eso es lo que hacen los #define. En nuestro ejemplo, x es 2+3, pues se ha escrito cuadrado(2+3): Entonces, el fragmento cuadrado(2+3) es reemplazado por 2+3*2+3, ya que el #define reemplaza textualmente cada copia de x por el texto indicado. Esta expresión, al no tener paréntesis, da por resultado 2+6+3=11, pero nosotros queríamos realizar (2+3)*(2+3)=25.

Esto podría resolverse si utilizamos paréntesis en todos lados:

#define cuadrado(x) ((x)*(x))

Este #define funciona correctamente, pero ahora quedó bastante más feo y difícil de leer. Una función como

int cuadrado(int x)
{
    return x*x; 
}

nos hubiera evitado todos estos problemas, pues en las funciones no se hace un reemplazo textual mecánico del código, sino que se calcula el valor indicado antes de comenzar a ejecutar la función. En el ejemplo con la función cuadrado, se calcularía el valor 2+3=5 antes de iniciar la función, de forma que cuando se comienza a ejecutar la función cuadrado, ya se tiene x=5. En este sentido, las funciones son más inteligentes que el #define.

En resumen, utilizaremos #define solamente cuando nos permite hacer algo que con una función no podríamos hacer. Por ejemplo, escribir algún tipo de for común, o resumir alguna instrucción que declare una variable, es algo que no podríamos reemplazar fácilmente por una función. En cambio, un simple cálculo como elevar al cuadrado sí es algo que podríamos hacer fácilmente con una función, y entonces generalmente conviene hacerlo así para evitar posibles errores, y no tener que llenar todo de paréntesis.

1)
Si bien tiene ciertas similitudes con una función matemática, la palabra función en C++ y en la matemática significan cosas bien diferentes
curso-cpp/modularizacion-funciones.txt · Última modificación: 2017/10/29 19:24 por santo