Universidade Federal de Minas Gerais
Instituto de Ciências Exatas
Departamento de Ciência da Computação

Algoritmos e Estruturas de Dados I

Funções

Assim como os arranjos da Computação podem “corresponder” aos vetores e matrizes da Matemática, o programador que usa a linguagem de programação C pode (i)definir e (ii) invocar funções que de certo modo evocam ou correspondem às funções da Matemática. No trecho de programa abaixo definimos e invocamos uma função bem simples correspondente à denominada função identidade

#include <stdio.h>
#include <stdlib.h>

int f(int x){ return x;} //definição

double g(double x){ return x;} //definição

int main(int argc, char *argv[]){
  int ii=f(123);
  int jj;
  jj=f(ii+2);
  double dd=g(123.456);
  printf("ii:%d\n", ii);
  printf("jj:%d\n", jj);
  printf("dd:%f\n", dd);
  system("PAUSE");     
  return 0;
}

No programa acima além da definição da função “main” são definidas duas funções com nomes ou identificadores f e g. A função f é a função identidade para valores do tipo int e a função g é a função identidade para valores do tipo double (em C não temos a capacidade de escrever uma função identidade que seja “genérica”, mas isso pode ser feito em C++). Lembre-se que este é um exemplo simples e sem muito significado só para exemplificar definição e invocação de funções. O exemplo ilustra também o fato de que uma função é definida uma vez e depois pode ser invocada em diversos pontos. No programa acima a função f é invocada na função “main” duas vezes. Mais à frente são discutidos diversos tópicos que esclarecem mais formalmente o programa acima, em particular o conceito de:

 REGISTRO DE ATIVAÇÃO<<<<< que corresponde a “contexto” na Matemática!

Falando de um outro ponto de vista: as funções correspondem a abstrações para trechos de programas. A concepção de uma função pode acontecer de várias maneiras, por exemplo podemos ter um extenso trecho de programa e identificamos uma certa “funcionalidade” dentro daquele trecho. Podemos então extrair parte do trecho para definir uma função, no local onde o trecho foi extraido colocamos uma chamada/invocação da função definida. No ponto que o programador deseja executar aquele trecho de programa ele invoca ou chama aquele trecho. Veremos abaixo alguns dos conceitos envolvidos. A possibilidade de poder abstrair de como implementar um trecho de programa e poder dar um nome a este trecho é um mecanismo muito importante. Todas as linguagens de alto-nível provêm alguma forma de abstração que segue essa descrição. Esta abstração se apresenta com vários nomes: procedimento (procedure), subrotina (subroutine), função (function), método (method), etc.

A função identidade apresentada acima é provavelmente a menor função que tem algum significado... mas uma função “nula” também pode ser definida:

void f(){}

A função f acima não retorna valor, não tem parâmetros, e não tem comandos! Esta definição corresponde a uma função “inútil”, mas são explicitados os elementos que não podem ser omitidos na definição: (i)o tipo de valor retornado (void corresponde a não haver valor a ser retornado), (ii)o nome da função sempre é seguido de abre e fecha parênteses, o nome segue as mesmas regras de outros identificadores da linguagem C, entre os parênteses ficam as definições de parâmetros e (iii) o corpo da função: se o programador não colocar nada o compilador considera que existe um “comando de retorno” ao ponto de chamada/invocação; A definição acima equivale a uma definição contendo o comando “return”:
void f(){return;}

 

De maneira convencional o ponto de entrada do fluxo de controle em um programa em C é feito por uma função denominada “main”.

Os programas desta disciplina, vistos até este ponto, apresentavam todo seu código fonte dentro de uma unidade da Linguagem C com o nome de main (algo parecido com):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]){

    /* codigo fonte */
}

Esta unidade é denominada função. A função main() é uma função especial pois é a função que serve como ponto de entrada do fluxo de controle nos programas C. Quando solicitamos ao sistema operacional a execução de um programa C, o fluxo de controle é passado ao primeiro comando da função main() (como é visto à frente existem mais aspectos a serem considerados).

Antes de podermos chamar ou invocar uma função é necessário que seja feita a declaração ou definição da função. Em algum momento antes da execução de um programa todas as funções declaradas devem também estar definidas A definição de uma função envolve vários conceitos que serão ilustrados de forma incremental. Vemos abaixo uma função bastante simples (não define nenhum parâmetro, não tem variáveis locais e não retorna nenhum valor):
#include <stdio.h>                        //1
#include <stdlib.h>                       //2
imprime(){                                //3
  printf("uma funcao simples!\n");        //4
}                                         //5
int main(int argc, char *argv[])          //6
{ imprime();                              //7
  system("PAUSE");                        //8
  return 0;                               //9
}
                                                               //10

No programa acima definimos uma função imprime(). Esta função não retorna valor. Uma função pode retornar ou não um valor. Quando a função não retorna nenhum valor, como no caso acima, podemos utilizar a palavra chave void no cabeçalho da definição. Uma função pode retornar um valor do tipo int, double, ou um ponteiro ou não retornar nenhum valor e neste caso podemos utilizar a palavra chave void. As funções podem ser definidas em diferentes permutações. Podemos reescrever o programa acima com a definição da função imprime() depois da função main().

Em tempo de execução, ao ser chamada ou invocada a função imprime(), o fluxo de controle desvia para o primeiro comando da função (mas antes do desvio é reservada e inicializada uma área especial da memória). Quando é executado o último comando de uma função, o fluxo de controle volta para o ponto posterior ao ponto onde a função foi chamada. O sistema de execução prepara uma área especial de memória denominada registro de ativação ou quadro de ativação (activation frame) para controlar as atividades de chamada, execução e retorno de uma função.

Na compilação do programa acima, o compilador gera código para uma chamada de função correspondente à linha 7 e gera código para a função correspondente às linhas 3, 4 e 5. Além disso gera código para que seja construído, de forma adequada, um registro de ativação, quando a função imprime() for invocado, ou seja, o código correspondente a linha 7, entre outros aspectos, salva no registro de ativação o endereço de retorno. O endereço de retorno é o endereço para onde desvia o fluxo de controle  quando a função termina (no caso linha 8).

Na execução do programa acima o fluxo de controle está inicialmente com o “Runtime C” e é criado um registro de ativação para invocar a função main(). Após isso é feito o desvio para o primeiro comando da função main(), linha 7. O código correspondente à linha 7 é uma invocação da função imprime(). É criado um novo registro de ativação com conteúdo adequado e é feito o desvio do fluxo de execução para o primeiro comando da função imprime(), isto é, linha 4. Após a execução da linha 4 o fluxo de controle executa o código que consulta o registro de ativação e retorna o fluxo de controle para a linha 8. A linha 9 corresponde a um código que retorna o fluxo de controle para o Runtime C e termina a execução do programa. A seqüência de execução (simplificada) do programa acima é: O Runtime C invoca a função main(). O primeiro comando da função main(), linha 7, cria um registro de ativação que, dentre outras informações, guarda o endereço de retorno. O código relativo à invocação da função imprime() desvia o fluxo de controle para o código correspondente à linha 4. A linha 4 corresponde a uma invocação da função printf(). Neste ponto é criado um registro de ativação (o terceiro!!) para a execução da função printf(). O controle é desviado para o código da função printf(). Quando termina a execução da printf() o controle retorna para o código correspondente à linha 5, destruindo o registro de ativação criado para a invocação da printf(). O código relativo à linha 5 consiste em recuperar o endereço de retorno contido no registro de ativação correspondente à invocação da função imprime(). O endereço de retorno corresponde ao código da linha 8 e o código correspondente a linha 9

  1. Fluxo de execução no Runtime C
  2. invocação de main - criação do registro de ativação da função main();
  3. execução do código correspondente à linha 7;
  4. invocação de imprime - criação do registro de ativação da função imprime();
    [o registro de ativação da main continua existindo!]
  5. execução do código correspondente à linha 4;
  6. invocação de printf() - criação do registro de ativação da função printf();
  7. execução do código da função printf();
  8. retorno da função printf() para o código correspondente à linha 5 - destruição do registro de ativação de printf();
  9. execução do código correspondente à linha 5: retorno da função imprime() para o código correspondente à linha 8 - destruição do registro de ativação da função imprime();
  10. execução do código correspondente à linha 9: retorno da função main() para o Runtime C - destruição do registro de ativação da função main().

Uma função pode retornar um valor (este nome “função” sugere que sempre deveria retornar um valor). Para isso devemos colocar no cabeçalho de definição da função, o tipo do valor que ela irá retornar. Neste caso a execuçao (não é o texto!) da função usualmente irá terminar quando for executado um comando da forma:
return <expressão>;
A expressão especificada no comando de retornar (return) deve ser de tipo compatível na definição do cabeçalho da função. Quando o fluxo de controle atinge um return é obtido o endereço de retorno do registro de ativação corrente, este registro de ativação corrente é destruido e o fluxo de controle retorna para o comando seguinte à invocação onde é tornado disponível o valor retornado. Quando o tipo da função é void a função deve usar somente o comando return sem a expressão.

Uma forma um pouco mais completa de escrever o programa acima é:

#include <stdio.h>
#include <stdlib.h>
void imprime(){
  printf("uma funcao simples!\n");
  return;
}
int main(int argc, char *argv[])
{ imprime();
  system("PAUSE");
  return 0;
}


Quando a função (i) não tem parâmetros (é definida com abre parênteses seguido de fecha parênteses), (ii) não retorna um valor (é definida como void) e não tem variáveis locais (não parecem declarações da forma int x; ou char c;), o registro de ativação serve basicamente para guardar o endereço de retorno do fluxo de controle.

Outra observação importante é que nesta disciplina vamos lidar com programas que têm apenas um fluxo sequencial de controle e neste contexto somente um registro de ativação está “ativo” a cada momento. Deve ser observado que se a chamada de funções é encadeada (f1() chama f2(), f2() chama f3(), f3() chama f4() etc) então é criada uma “cadeia” ou “pilha” de registros de ativação. Cada chamada de função “desativa” o registro corrente e um novo registro é criado de forma encadeada ou empilhada sobre os registros anteriores. Quando um registro de ativação é destruído o registro de ativação que estava “ativo” quando ele foi criado é retomado como registro de ativação corrente.

Considere o seguinte programa em C
#include <stdio.h>
#include <stdlib.h>
f1(){return;}
f2(){f1(); return;}

main(int argc, char *argv[]){
  f1();
  f2();
  return;
}

A compilação e carga deste programa na memória gera blocos de código executável. A execução do programa promove a criação e destruição de vários registros de ativação. Conforme ilustrado na figura a seguir:

A parte de cima da figura mostra uma possível organização para o código executável na memória. Mostramos parcialmente o código do sistema de tempo de execução (Código do Runtime), o código de f1, o código de f2 e ao código da função main. A parte de baixo da figura deve ser entendida como sendo diferentes momentos no tempo de uma possível organização para os registros de execução. No caso da figura são mostrados sete tempos de execução. O primeiro tempo tem apenas o registro de ativação da função main e ele mostra o campo correspondente ao endereço de retorno para o código do Runtime (O código Runtime é que invoca a main). No primeiro tempo o fluxo de execução está no primeiro comando da main e antes da chamada de f1.O segundo tempo já mostra o fluxo de execução após a chamada de f1 na main (o fluxo de execução está no código de f1 prestes a executar o único comando que é um “return”). O momento mais interessante é o quinto tempo onde o fluxo de execução já passou pela chamada de f2 na main, já passou pela chamada de f1 em f2 e está prestes a executar o único comando de f1. Esta figura mostra que não é trivial entender toda a dinâmica da criação e destruição dos registros de ativação.

Se você tiver dificuldades em entender a figura acima não se preocupe por agora. O ponto mais importante da figura é você entender que a definição de uma função em termos do tempo de compilação/edição/carga corresponde à parte de cima da figura. Cada definição de função define um bloco de código executável em memória. Quando é executado o código das funções (tempo de execução) passará a existir um registro de execução para cada execução de chamada de função. Chamadas aninhadas de funções geram simultaneidade de existência de registros de ativação.

---

Deve ser observado que o valor retornado pela função main é do tipo int e em quase todos os ambientes de execução de programas este valor pode ser consultado. No ambiente Windows (no processador de comandos cmd) o valor retornado pela função main pode ser consultado através da variável de ambiente errorlevel, exemplos:
c:\programa
c:\echo %errorlevel%

ou ainda
c:\programa
c:\if errorlevel 1234 etc

---

 A seguir vamos introduzir novos elementos na definição de funções. Observe a nova função imprime do programa abaixo:

#include <stdio.h>
#include <stdlib.h>
void imprime(char * m){
  printf(“%s”, m);
  return;
}
int main(int argc, char *argv[])
{ imprime("uma funcao simples!\n");
  system("PAUSE");
  return 0;
}

 

A nova definição de imprime() possui agora um parâmetro do tipo ponteiro para caractere. Esta função, para ser invocada adequadamente deve prover um parâmetro. A seqüência do fluxo de execução é similar ao programa anterior, a principal diferença é que o código correspondente à invocação da função imprime(), antes de fazer a invocação irá atribuir o endereço da cadeia de caracteres "uma funcao simples!\n" para o parâmetro m. Os parâmetros são elementos similares a variáveis e ficam disponíveis no registro de ativação de uma função. O programador de uma função pode usar os parâmetros da mesma forma que podem usar uma variável declarada localmente à função. No caso acima o valor do parâmetro é passado para a invocação da função printf().

As variáveis definidas dentro de uma função, da mesma forma que os parâmetros, têm seu espaço definido dentro do registro de ativação e portanto todas as variáveis definidas dentro de uma função (juntamente com os parâmetros) serão destruídas quando for destruido o registro de ativação correspondente. As variáveis locais e os parâmetros só podem ser utilizados na função onde foram definidos. Este tipo de variável local às funções são conhecidas como variáveis “automáticas” (“automatic variables”). Observe portanto que as variáveis locais ou automáticas têm um ciclo de vida amarrado ao ciclo de vida dos registros de ativação da função correspondente. A linguagem C prove um mecanismo de definição de variáveis externas (ou “globais”) às funções. Estas variáveis podem ser utilizadas por todas as funções. A idéia é que existem variáveis comum a todas as funções e comum às diferentes vidas (invocações) de uma mesma função. As variáveis definidas fora de uma função (variáveis globais) têm um ciclo de vida amarrado ao ciclo de vida do programa, ou seja, enquanto o programa estiver em execução elas estão disponíveis. Mais à frente mostraremos como definir variáveis “externas” (globais) às funções.

A definição das variáveis locais de uma função podem ser modificadas pela palavra reservada static. Conforme já discutido as variáveis locais são criadas e destruídas juntamente com os registros de ativação. Mas se quisermos preservar as variáveis entre invocações das chamadas devemos defini-las com o modificador static. Compare o funcionamento das duas funções abaixo:

#include <stdio.h>
#include <stdlib.h>

int f1(){ int i=0; return ++i;}
int f2(){ static int i=0; return ++i;}

int main(int argc, char *argv[])
{
    int i;
  for(i=0; i<5; i++) printf("%d\n",f1());
  for(i=0; i<5; i++) printf("%d\n",f2());
  system("PAUSE");     
  return 0;
}

No caso da função f1 a  cada chamada é criada a variável i e inicializada com o valor 0 e o valor retornado é sempre 1. No caso da função f2 a definição da variável local i é modificada por static e a cada chamada a função retorna um valor diferente.

A palavra reservada static pode também modificar a definição de uma função. Considere o trecho de programa abaixo:

static int f1(){<corpo>}

int f2(){<corpo>}

A definição da função f2 está disponível para qualquer outro arquivo que for ligado-editado com o arquivo que contém o trecho acima. Mas a definição da função f1 está disponível apenas no arquivo do trecho. Uma outra maneira de dizer isso é que a linguagem C exporta os nomes de função de maneira “default”, se quisermos impedir a exportação de uma função temos que modificá-la com static. As variáveis globais modificadas por static também só ficam disponíveis naquele arquivo, e não podem ser utilizadas em outros arquivos.

A função abaixo mostra um exemplo de passagem de parâmetros um pouco mais geral.  Considere a definição:
void f(int p1, double p2, int *p3, int p4, char *p5){
  printf("int %d\n",p1);
  printf("double %f\n",p2);
  printf("arranjo de inteiros:\n");
  int i;

  for(i=0; i<p4; i++) printf(“%d “,p3[i]);
  printf(“\n”);
  printf(“%s\n”,p5);
}

Considere a invocação:
int a[]={1,2,3};
f(123, 12.34,a,3, "ola!");

Antes do controle desviar para o primeiro comando da função f, será feita a avaliação das expressões correspondentes a cada parâmetro.[É usual que o termo “argumento” seja usado para uma expressão na invocação e que o termo “parâmetro” seja usado para o nome correspondente na definição, outros termos são parâmetro real/parâmetro formal, ou ainda argumento real/argumento formal.] Após ser feita a avaliação das expressões (dos argumentos) será feita a atribuição dos valores resultantes para cada parâmetro. Os parâmetros ficarão disponíveis no registro de ativação que é construído para a invocação.

A linguagem C tem dois estilos de função: “estilo-antigo” e “estilo-novo”. As funções do estilo novo devem ter o tipo dos parâmetros explícitos e isto faz parte do tipo da função. As funções do estilo antigo não precisam explicitar o tipo dos parâmetros. Os dois trechos de programa abaixo ilustram os dois estilos: estilo original, estilo ANSI C:

double f(a, b){ int a; double b; <resto do corpo de f>} //original

double f(int a, double b){<corpo de f>} //ANSI C

 

No estilo novo de funções (ANSI C) o número e o tipo dos parâmetros na definição devem sempre ser compatíveis com o número e o tipo das expressões na invocação. Os parâmetros na definição da função são conhecidos como parâmetros formais, os valores ou expressões dos parâmetros na invocação da função são conhecidos como parâmetros reais. A invocação de uma função faz com que os valores dos parâmetros reais sejam atribuídos ao parâmetros formais. Cada invocação de uma função dá origem a um registro de ativação contendo os parâmetros formais inicializados com os valores obtidos na avaliação das expressões correspondentes aos parâmetros reais. Se fizermos uma atribuição a um parâmetro formal esta atribuição, no caso da linguagem C, não afeta o parâmetro real correspondente:
#include <stdio.h>
#include <stdlib.h>
void f(int pf){
     printf("f recebeu valor:%d\n" ,pf);
     pf=5;
     printf("f atribuiu %d ao parametro\n", pf);
}

int main(int argc, char *argv[])
{
    int pr=3;
    printf("parametro real antes da chamada:%d\n",pr);
    f(pr);
    printf("parametro real apos chamada:%d\n",pr);
 
  system("PAUSE");     
  return 0;
}
 

O programa acima imprime:
parametro real antes da chamada:3
f recebeu valor:3
f atribuiu 5 ao parametro
parametro real apos chamada:3

O modelo de passagem de parâmetro da linguagem C é denominado passagem de parâmetro por valor. Existem outros mecanismos de amarramento (binding) de parâmetros em outras linguagens. Os parâmetros e as variáveis declaradas dentro de uma função só estão disponíveis no escopo desta função. O registro de ativação contém os parâmetros e irá conter todas as variáveis locais que forem definidas; quando o registro de ativação é destruído as variáveis locais e parâmetros deixam de existir. Se quisermos que uma função f1 altere uma variável em f2 a chamada de f1 em f2 terá que passar o endereço da variável (ponteiro!) e a definição de f1 tem que levar isto em conta:

#include <stdio.h>
#include <stdlib.h>
void f(int *pi){
     printf("f recebeu valor:%d\n" ,*pi);
     *pi=5;
     printf("f atribuiu %d ao parametro\n", *pi);
}

int main(int argc, char *argv[])
{
    int pr=3;
    printf("parametro real antes da chamada:%d\n",pr);
    f(&pr);
    printf("parametro real apos chamada:%d\n",pr);
 
  system("PAUSE");     
  return 0;
}

***************
Importante- parâmetro do tipo ARRANJO não é arranjo!!!****Um aspecto da linguagem C que é problemático para o aprendiz é o fato de a semântica da definição de um arranjo na lista de parâmetros ser diferente da definição de um arranjo em outros lugares. Considere a definição de um arranjo no corpo da função main():
#include <stdio.h>
#include <stdlib.h>
 
int main(int argc, char *argv[]){

  int ai[4];

No trecho acima o compilador trata a definição de ai da seguinte maneira: passa a existir 4 posições de memória do tipo int cada uma identificada por ai[0], ai[1], ai[2] e ai[3]; O nome ai é tratado como uma constante do tipo endereço de int. A constante ai tem o mesmo valor de &ai[0]. O fato importante é que o identificador ai não corresponde a uma variável ou de maneira mais precisa: nomes de arranjo não são considerados “left-values” no corpo das funções. Mas parâmetros de funções devem corresponder a “left-values” pois na invocação  da função haverá a avaliação das expressões seguida da atribuição dos valores resultantes aos parâmetros que são alocados no registro de ativação correspondente. E daí surge o importante aspecto de como tratar um arranjo na lista de parâmetros de uma função, ou seja, como tratar a definição de f() no seguinte trecho de programa:

#include <stdio.h>
#include <stdlib.h>

void f(int pa[4]){. . . . .
 
int main(int argc, char *argv[]){

  int ai[4]; f(ai);

O compilador quando encontra uma definição de arranjo na lista de parâmetros formais não trata de forma semelhante à definição de arranjos no corpo das funções. Um arranjo definido na lista de parâmetros passa a ser tratado como um ponteiro! ou falado de outra maneira: Um identificador tipado como arranjo na lista de parâmetros formais é considerado um ponteiro!!

 No programa acima o parâmetro pa corresponde a uma variável do tipo ponteiro! Na chamada da função f será atribuido para a variável pa o endereço do arranjo ai (ai ou &ai[0]).

Considere o seguinte trecho de programa C:

void f(char *p[]){ //definição de f...

int main(){
  char *v[]={″a″, ″bc″, ″def″};
  f(v)

Deve ser observado que o parâmetro formal p tem uma sintaxe similar ao arranjo v da função main(), mas p é um ponteiro e v é um arranjo. A figura abaixo mostra estes elementos no instante em que já foi criado o registro de ativação da função f passando v como argumento. o resultado da avaliação da expressão “v” equivale a &v[0] e é atribuido ao ponteiro p.

-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-

 

A tentativa de definir um arranjo bidimensional (ou n-dimensional) também é tratada da mesma forma ao invés de ser definido um arranjo será definido um ponteiro. A diferença é que as dimensões são consideradas:. (i) em int ai[N]  ai corresponde a um ponteiro para int  e N é desconsiderado, (ii) em int ai[N][M] ai corresponde a um ponteiro para arranjos de M ints e N é desconsiderado, (iii) em int ai[N1][N2][N3] ai corresponde a um ponteiro para N2 arranjos de N3 ints e N1 é desconsiderado, e assim sucessivamente. Um arranjo representando uma matriz 3x4 definido na lista de parâmetros de uma função desta forma:

void f(int m3x4[3][4]){...

também pode ser definido da forma:

void f(int m3x4[][4]){...

mas o compilador não saberá como indexar corretamente se for usado:

void f(int m3x4[][]){...

Se quisermos explicitar a natureza de ponteiro do parâmetro devemos usar a notação “ponteirística”, por exemplo:

void f(int (*m3x4)[4]){...
ou usando outro nome mais significativo:
void f(int (*ponteiroParaArranjosDe4colunasDeInt)[4]){...

O que deve ser lembrado é que arranjos nas listas de parâmetros não são como arranjos no corpo das funções ou como arranjos globais.

Exemplo de programa que utilize arranjos bi-dimensionais como parâmetro. Este programa relaciona-se com representação de relação binária.

-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-

Uma função pode retornar um valor. Para isso devemos colocar no cabeçalho de definição, ao invés da palavra chave void, o tipo do valor que a função irá retornar. Neste caso a execuçao (não é o texto!) da função usualmente irá terminar quando da execução de um comando da forma:
return <expressão>;
A expressão especificada no comando de retornar (return) deve ser de tipo compatível na definição do cabeçalho da função. A função abaixo recebe como parâmetro um caractere hexadecimal e retorna um valor inteiro correspondente ao caractere:
int charHex2int(char c){
  switch(c){
    case '0': case '1': case '2': case '3': case '4':
    case '5': case '6': case '7': case '8': case '9':
      return c-'0';
    case 'A': case 'B': case 'C': case 'D': case 'E': case 'F':
      return c-'A'+10;
    case 'a': case 'b': case 'c': case 'd': case 'e': case 'f':
      return c-'a'+10;
    default:  return -1;
  }
}
Observe que as expressões nos comandos de retorno são de tipo int. No local onde existe a <chamada da função>, após a execução da função, passa a ficar disponível o valor retornado. Se o valor não for utilizado ele “desaparece” (ou seja podemos invocar uma função que retorna um int sem utilizar este valor). Por outro lado existem várias restrições sobre que tipo de elemento uma função pode retornar.

Observe as funções f1 e f2 abaixo:

void f1(){return;}

void f2(){}

No caso de f1 o retorno é explícito; no caso de f2 o retorno ocorre pelo que é denominado “drop off”( o fluxo de controle saiu do bloco!), podemos considerar que o compilador coloca uma instrução de return ao final do bloco da função f2.

Uma função pode ter variáveis locais. As variáveis declaradas dentro de uma função são de acesso exclusivo daquela função. Na linguagem C não podemos definir uma função dentro de outra função (não existem definições aninhadas de funções). A definição de uma função define um espaço de nomes. Uma função só pode acessar dados de outra função se esta outra função passar um ponteiro para seus dados.

Além dos (i)dados locais, (ii) dos dados de outra função manipulados através de ponteiros uma função pode acessar variáveis “globais”. A estrutura de um programa C é formada não só pelo conjunto das funções mas também por um conjunto de variáveis definidas externamente (ou globalmente) às funções.

considere um arquivo arq.c contendo o seguinte trecho de programa C:

int vg=3;

se este arquivo for compilado e editado-ligado a este main.c:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]){
  extern int vg;
  int vl=5;
  printf("%d %d\n",vg, vl);
  system("PAUSE");     
  return 0;
}

O resultado é um programa onde a função main além de ter acesso à variável “local” vl tem acesso a uma variável global vg. Observe o uso da palavra reservada extern como modificador da declaração de vg. A variável vg é definida no arquivo arq.c e declarada na função main do arquivo main.c conforme pode ser visto acima. Observe que a semântica da linguagem C é a disponibilização ou exportação por “default”. Se quisermos limitar o escopo de uma variável global ao arquivo onde ela foi definida temos que utilizar o modificador static.

A linguagem C permite a declaração de uma função em um local ou arquivo e a definição da função em outro local ou arquivo. Considere o trecho de programa abaixo:

int f1();
int f2(){ <corpo de f2>}

A função f1() foi declarada mas não foi definida, a função f2() foi definida (e considerada declarada!). A declaração pode ser no estilo original ou no estilo ANSI C:

int f(); //original

int f(int p1, int p2); //ANSI C

 

Um ambiente de desenvolvimento do tipo DEV-C++ ou code::blocks dá suporte ao desenvolvimento de programas onde o código fonte corresponde a vários arquivos contendo macro-comandos, definições de variáveis e funções dentre outros elementos. Como exercício utilize os arquivos deste diretório para construir um programa definido no livro de Kernigham & Ritchie (The C Programming Language).

Funções recursivas

Uma função pode, em sua definição, invocar a si mesma! Quando uma função invoca a si mesma em sua definição dizemos que ela é uma função recursiva. O programa abaixo mostra uma função para calcular o fatorial de um número em uma versão recursiva e outra não recursiva:
#include <stdio.h>
#include <stdlib.h>

int fatNaoRecursivo(int n){
    if(n<2) return 1;
    int f=1;
    int i;
    for(i=2; i<=n; i++) f*=i;
    return f;
}
int fatRecursivo(int n){
    if(n<2) return 1;
    return fatRecursivo(n-1)*n;
}

int main(int argc, char *argv[]){
    printf("%d\n",fatNaoRecursivo(5));
    printf("%d\n",fatRecursivo(5));
  system("PAUSE");     
  return 0;
}
 

No programa acima vemos que a versão não recursiva que implementa o cálculo do fatorial utiliza um comando for para iterar as multiplicações necessárias. Para podermos entender a versão recursiva da função temos que lembrar que existe um registro de ativação para cada invocação da função. A tabela abaixo mostra algumas características da correlação entre registros de ativação e o parâmetro n:
 

n

No. de registros
de ativação

valor
retornado

0

1

1

1

1

1

2

2

2

3

3

6

https://goo.gl/v3hrbl  <<<nesta página é possível visualizar parcialmente os registros de ativação!
Como regra geral é sempre preferível utilizar a versão não recursiva pois as versões recursivas têm, no mínimo, o preço da criação e destruição dos registros de ativação. Considere a série de Fibonacci: 0,1, 1, 2, 3, 5, 8, 13, 21, 34, ... Esta série inicia-se com o elemento de ordem 0 igual a 0 e o elemento de ordem 1 igual a 1, o elemento de ordem n é igual a soma dos elementos de ordem n-1 e ordem n-2. O programa abaixo mostra uma solução recursiva para calcular o termo de ordem i:
#include <stdio.h>
#include <stdlib.h>
int fib(int n){
  if(n<=0) return 0;
  if(n==1) return 1;
  return fib(n-1)+fib(n-2);
}

int main(int argc, char *argv[]){
  int n=4;
  printf("o valor do termo de ordem %d"
         " da serie de Fibonacci e' %d\n",n,fib(n));
  system("PAUSE");     
  return 0;

A tabela abaixo mostra a correlação entre o valor de n e o número de registros de ativação de fib():
 

n

Número de Registros
de ativação

valor retornado

0

1

0

1

1

1

2

3

1

3

5

2

4

9

3

 

Ponteiros para Funções

A linguagem C permite a definição de variáveis do tipo “ponteiro para função”. A metáfora neste caso é que uma variável deste tipo quando usada de “certa” maneira pode receber o “endereço” [do código?] da função e pode “invocar” a execução do código da função. Observe o programa abaixo:
#include <stdio.h>
#include <stdlib.h>

void f(){ printf("Eu sou a funcao f\n");}
void g(){ printf(“Eu sou a funcao g\n”);}

int main(int argc, char *argv[]){
  void (*pf)();
  pf=f; /*sem acucar sintático do & */
  (*pf)(); /*invoca f utilizando a variavel pf*/
  pf=g;
  (*pf)(); /*invoca g utilizando a variavel pf */
  system("PAUSE");     
  return 0;
}

Os projetistas de C decidiram que não podemos aplicar o operador & sobre o nome da função! O nome da função sem ser seguido  do abre e fecha parênteses é uma “constante ponteiro” de forma similar à decisão de que um nome de arranjo sem ser seguido de abre e fecha colchetes é uma “constante ponteiro”. Ao atribuir o nome da função ao ponteiro estamos atribuindo algum tipo de endereço a ele (inicio do código?). A invocação da função é feita fazendo a indireção(“derreferenciando” o ponteiro) com o uso do operador de indireção *) seguido da aplicação do operador “abre e fecha parênteses” contendo os argumentos. Vários compiladores aceitam a “indireção implícita” e podemos invocar uma função apontada por pf de duas formas: pf() ou (*pf)(). No exemplo acima o ponteiro pf primeiramente aponta para a função f e depois passa a apontar para a função g. Dentre outros aspectos os ponteiros para função permitem a passagem de funções como parâmetro e permitem ainda o retorno de uma função por parte de outra função.

 

Tipos de variáveis na linguagem C

No material sobre variáveis alertamos que existem vários tipos de variáveis. Em particular alertamos que as variáveis podem ser definidas dentro ou fora das funções e que as variáveis podem ser definidas com ou sem a palavra chave static. No material sobre variáveis alertamos também que as variáveis definidas dentro de funções sem o modificador static (variáveis automáticas) têm o conteúdo indefinido quando são alocadas (lixo de memória).

A linguagem C permite a definição de variáveis ligadas ao bloco da função ou a outros blocos aninhados a este. No trecho de programa abaixo:
  if(n>0){
    int i;
    for(i=0; i<n; i==) ...

o escopo da variável i é o bloco “verdadeiro” do “se”, e pode haver uma variável i externa a este bloco! Uma variável automática declarada e inicializada em um bloco é inicializada cada vez que o fluxo de controle atinge este bloco. Uma variável estática (static) é inicializada somente na primeira vez que o fluxo de controle chega até o bloco correspondente.

As variáveis definidas com o modificador static (variáveis static não são destruídas e criadas a cada invocação da função onde foram definidas) são garantidas de serem inicializadas com zero. No programa abaixo a impressão de vne corresponde a qualquer valor e ve corresponde a um zero:

  int vne;
  static int ve;
  printf("%d %d\n", ve, vne);

O trecho de programa acima deve ser condenado pois faz uso de conteúdo de variável que não foi inicializado ou atribuido. Na maioria das organizações de desenvolvimento de software e a boa prática de programação recomendam que não se deve confiar na inicialização implícita!

Para variáveis externas (variáveis declaradas com extern) e variáveis estáticas, a inicialização só pode ser feita utilizando expressões constantes. O seguinte trecho de programa não compila:
  int i=2;
  static int j=i;

Por outro lado, no caso de variáveis automáticas podemos utilizar expressões envolvendo valores previamente definidos, até mesmo chamadas de funções:
int f(int x){
  int y=0;
  int z=x-1;

Conforme foi discutido no material de arranjos uma “variável” do tipo arranjo não é exatamente uma variável em termos da possibilidade de podermos manipula-la como um todo. Vamos esclarecer, considere o trecho:
int i=8;
int j;
j=i;

Tentando fazer algo similar a isso com arranjos iremos entender porque uma variável do tipo arranjo não é exatamente uma variável:
int ai[2]={100,200};
int aj[2];
aj=ai;

O trecho de programa acima não é correto porque a Linguagem C não prevê a manipulação de um arranjo como um todo. Por outro lado podemos manipular as estruturas como um todo:
struct s{int a[2]; int j;} sa={{100,200}, 300};
struc s sb;
sb=sa;

Todo o conteúdo de sa (em particular o conteúdo do arranjo!) é copiado de sa para sb.

Repetindo um aspecto importante já discutido: A semântica de atribuição entre parâmetros formais e parâmetros reais na invocação de funções tem portanto um problema: Como lidar com a passagem de arranjos como parâmetro? (Todos os tipos de variáveis podem ser alvo de atribuição exceto os arranjos!). Os projetistas decidiram manter as restrições de atribuição entre arranjos (não pode haver atribuição de arranjos mesmo no contexto de funções) portanto funções não retornam arranjos (uma função pode retornar um apontador para arranjo). Com relação aos parâmetros os projetistas da linguagem C permitiram o “açucar sintático” de arranjos:
int *f(int a[]){...}

A função f acima aparentemente recebe um “arranjo” como parâmetro, mas a é um ponteiro que irá funcionar como determinado pelos projetistas com relação à equivalência entre indireção e indexação:  <exp1>[<exp2>] equivale a *(<exp1> + <exp2>). Esta sintaxe pode ser enganadora e pode induzir alguém a acreditar que um arranjo corresponde a uma variável semelhantemente às variáveis do tipo apontador. Além disso podem acontecer fatos “estranhos” ou “problemáticos”:
int g(int a[]){return a[-1];}
...
     int a[2]={100,200};
    printf("%d\n", g(&a[1]));

O que parece ser um “arranjo” na definição de parâmetros de uma função é tratado como um ponteiro.

O trecho acima imprime o valor 100, utilizando indexação com valor negativo!

No material sobre arranjos ainda não haviamos discutido as funções. Os projetistas da linguagem C não permitem um arranjo onde os elementos são do tipo função (tente imaginar o que seria isso!), mas é permitido um arranjo de ponteiros para funções. A sintaxe sem o uso do “typedef” é bem convoluta, mas com o uso de “typedef” fica razoavelmente legível:
typedef double (*PonteiroParaFuncao)(double);
...
PonteiroParaFuncao apf[10];
apf[0]=&funcao;
printf("%f\n", (*apf[0])(2.1));

Na linguagem C a indireção no uso de apf para invocar as funções é opcional, portanto funciona também:
printf("%f\n", (apf[0])(2.1));

 

 

Listas de parâmetros com tamanho variável (Variable-length Argument Lists)

A linguagem C oferece mecanismos para definir uma função que pode ser chamada com 1, 2, ou mais argumentos. O cabeçalho da função deve ser definido da forma (observe que deve haver 1(um) ou mais parâmetros com nome e a lista termina com a notação de reticência [três pontos])

<tipo> <nome> ( <parametro com nome 1>, <parametro com nome 2> ...)

O arquivo de cabeçalho padrão <stdarg.h> contem um conjunto de definições de macros para lidar com os argumentos. O tipo va_list pode ser usado para definir uma espécie de “ponteiro de argumentos”. A macro va_start inicializa um “ponteiro de argumentos” para apontar para um primeiro argumento (A linguagem C exige que o primeiro argumento tenha nome, somente os “outros” argumentos podem ser “anônimos”!). =A cada “chamada” va_arg retorna um argumento e incrementa o “apontador de argumentos”. A macro va_end deve ser chamada antes do retorno e faz a faxina que for necessária. O programa abaixo define uma função que retorna a soma de seus argumentos. O primeiro argumento diz quantos argumentos devem ser considerados:

#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>

int soma(int na, ...){
  va_list pa;
  int i,k, acum=0;
  va_start(pa, na);
  for(i=0; i<na; i++){
    k=va_arg(pa, int);
    acum+=k;
  }
  va_end(pa);
  return acum;
}

int main(int argc, char *argv[]){
  printf("%d\n",soma(0));
  printf("%d\n",soma(1,10));
  printf("%d\n",soma(2,10,20));
  printf("%d\n",soma(3, 10,20,30));
  system("PAUSE");     
  return 0;
}


Exercícios:

  1. Escreva uma função que recebe um arranjo de inteiros como parâmetro e devolve o índice do maior elemento do arranjo.

int indMax(int *a, int tamanho)

  1. Escreva uma função que recebe um arranjo de valores do tipo double e devolve a soma dos elementos deste arranjo.

double somatorioDouble(double *ad, int tamanho)

  1. Escreva uma função que recebe um arranjo de inteiros e devolve um valor lógico (int) informando se todos os elementos do arranjo são iguais.

int iguais(int *a, int tamanho)

  1. Escreva uma função que recebe dois arranjos de valores do tipo double e devolve o produto interno correspondente a vetores que sejam representados por estes dois arranjos.

double prodInterno(double *a1, double *a2, int tamanho)

  1. Escreva uma função não recursiva que calcula o termo de ordem n de uma série de Fibonacci.
    int fib(int n)
  2. Considerando que as variáveis de uma função são “destruidas” junto com os respectivos registros de ativação, explique o problema da seguinte função:
    int *f(){ int a=10; int *pa=&a; return pa; }
  3. Escreva um programa definindo uma função com número variável de argumentos; o primeiro argumento define o número de argumentos e a função retorna a média aritmética dos argumentos.
  4.  

Exercícios extras:

Reescreva o programa de multiplicação de matrizes utilizando funções com nomes ilustrativos da operação.

Reescreva os programas relacionados ao jogo da velha e ao Nurikabe utilizando funções. Veja NESTA LIGAÇÃO uma sugestão/possibilidade de como organizar um programa para jogar o jogo da velha utilizando funções (a função principal não está implementada).