ELC1065 - Laboratório de Programação I - UFSM

Turmas SI2 e CC2, primeiro semestre de 2020

Professor: Benhur Stein (benhur+l1@inf.ufsm.br)
Horário: ter, qui, 14h30

Descrição do trabalho antecipado. Envie um mail pro professor se quiser participar.

Desenvolvimento da disciplina

Os principais canais de comunicação são esta página e e-mail para benhur+l1@inf.ufsm.br. Foi criado um servidor discord para a disciplina, com encontros no horário das aulas. Outros canais que eventualmente sejam criados serão comunicados aqui.

Esse e-mail pode ser usado para o envio de perguntas, exercícios, reclamações, sugestões, críticas e o que mais for, durante todo o período em que durar a disciplina.

O calendário deste semestre foi dividido em várias etapas, a primeira em REDE, até outubro/2020, depois a suspensão do semestre para o desenvolvimento do 2o semestre em REDE, depois a retomada do primeiro semestre, possivelmente de forma presencial. Nessa retomada, é previsto que o assunto da disciplina seja visto em sua totalidade, da forma mais presencial que for possível. As avaliações da disciplina estão previstas de acontecerem nesse período de retomada. Excessões podem ser combinadas com o professor. O desenvolvimento da disciplina na retomada muito provavelmente não vai acontecer ainda da forma como seria em situação normal. Recomenda-se que os alunos, tanto quanto possível esforcem-se para avançar no conteúdo.

Ementa

Ver aqui.

Distribuição Linux

A disciplina é desenvolvida usando a linguagem de programação C. Os alunos deverão desenvolver programas nessa linguagem. Esses programas serão avaliados em ambiente linux, usando o compilador gcc. Sugere-se fortemente que os programas sejam desenvolvidos, ou ao menos testados, nesse ambiente.

Pode ser qualquer distribuição linux, com um editor de texto e o compilador gcc. Para o trabalho final, será necessário também ter a biblioteca allegro5 instalada.

Porteus é uma distribuição linux bem pequena e autocontida. Basta preparar um pendrive como descrito abaixo (ou no link acima), e depois inicializar um computador PC (ou uma máquina virtual compatível) que carregue o sistema a partir desse pendrive, e executar o terminal. Esse pendrive está pré-instalado com o que será necessário para a disciplina (pelo menos até a metade do semestre). Para instalar: expanda o arquivo porteus.zip na raiz de um pendrive com pelo menos 500MB livres. Serão criados 2 pastas, boot e porteus e o arquivo USB_INSTALLATION.txt. O pendrive pode conter mais coisas, mas essas pastas devem estar na raiz. Siga as instruções no arquivo USB_INSTALLATION.txt. (Basicamente, o que as instruções dizem é: Vá para o diretório boot e execute o programa Porteus-installer-for-Windows.exe ou Porteus-installer-for-Linux.com (dependendo do sistema operacional em que estiver) como administrador. Confirme a localização do pendrive. Esse programa altera o pendrive para que ele possa ser usado para inicializar o computador. Depois disso, o computador deve poder ser iniciado a partir desse pendrive.)

Pode-se também utilizar uma máquina virtual para executar um ambiente linux.

Pode-se também utilizar, pelo menos no início da disciplina, um ambiente online de desenvolvimento, como o onlinegdb.

Material Auxiliar

Curso de programação C da UFMG

Apostila da UFPR

Ajude o robozinho a iluminar o mundo (ajuda no desenvolvimento de lógica de programação, fundamental para o conteúdo da disciplina) lightbot (tem um link bem embaixo da página para executar no navegador).

Ajude o passarinho a detonar o porco (semelhante ao anterior) code.org

Texto sobre aprender a programar (ou outra coisa) rápido norvig – tradução para português

O PET oferece tópicos de apoio a disciplinas iniciais.

Introdução

Isto é um resumo do que foi visto em aula no início do semestre. Recomendo ler o capítulo 4 da apostila da UFPR, que descreve melhor e de forma mais completa este assunto.

Um computador é uma máquina que manipula números. Ela é formada por 3 componentes principais: unidade central de processamento, memória e dispositivos de entrada e saída. Um dispositivo de entrada é capaz de converter alguma grandeza externa ao computador em um conjunto de números que represente essa grandeza. Um dispositivo de saída faz o contrário: converte um conjunto de números em algo externo ao computador. Toda a interface com o mundo externo ao computador (e com humanos) é realizada usando esse tipo de dispositivo. A unidade de memória é capaz de guardar um grande conjunto de números. Ela tem uma certa capacidade, que é a quantidade de números que ela é capaz de guardar. Cada um desses números é colocado em uma posição independente da memória, que é capaz de manter o valor de um número. O número armazenado em uma posição pode ser alterado, e é mantido até que seja alterado novamente, por um comando à memória. A unidade central de processamento (UCP ou CPU) realiza as manipulações de números. Ela está ligada às outras duas unidades, e é ela que as comanda. Todo o funcionamento do computador é controlado pela CPU. A CPU somente é capaz de realizar operações relativamente simples de manipulação desses números, mas faz isso de forma muito rápida, tipicamente alguns bilhões de operações a cada segundo.

Cada uma das operações básicas que a CPU é capaz de realizar é chamada de instrução. Tipicamente uma CPU tem um repertório de algumas dezenas ou centenas de instruções. Os principais tipos de instruções são:

A CPU é dividida em uma unidade de controle e uma unidade lógica e aritmética. A unidade lógica e aritmética é capaz de realizar as várias manipulações sobre os dados, e a unidade de controle é quem comanda o funcionamento de toda essa máquina. A unidade de controle cadencia o funcionamento, realizando a execução de uma instrução por vez. Para isso, ela tem codificada internamente a sequência de comandos a ser realizados sobre cada um dos outros componentes para que cada instrução seja realizada. Por exemplo, para realizar uma instrução de soma, ela vai inicialmente operar sobre a unidade de memória realizando uma leitura para obter o primeiro número a ser somado, depois realizar outra dessas operações para obter o segundo número, depois vai transferir esses dois números para a unidade lógica e aritmética e comandá-la a realizar uma manipulação que corresponde à soma, depois pegar o resultado e então finalmente realizar uma operação de gravação na memória para colocar lá esse resultado.

Para a unidade de controle saber que tem que realizar essa instrução e não outra, tem uma forma de se codificar com precisão cada instrução. Isso é realizado de forma numérica. Cada instrução possível tem um número que a identifica. Para as instruções que precisam de mais detalhes (chamados parâmetros), esses parâmetros também são codificados na forma de números. Por exemplo, a instrução de soma acima é parametrizada para identificar quais números da memória devem ser somados e qual número da memória vai ser substituído pelo resultado. Para isso, cada posição da memória é identificada por um número sequencial. A instrução de soma acima poderia ser codificada com os números 29 45 46 87, por exemplo (29 seria o código da instrução de soma, 45 a identificação da posição da memória onde está o primeiro número a ser somado, 46 a posição do segundo e 87 a posição onde será colocado o resultado). De forma semelhante, cada dispositivo de entrada e saída também é identificado de forma numérica, sendo possível representar qualquer instrução como uma sequência de números. Quando apresentada a uma tal sequência, a unidade de controle usa o primeiro desses números para identificar a instrução a ser realizada, o que permite selecionar o conjunto de passos necessários para a sua execução; durante essa execução, ela usa os demais números para parametrizá-la. Quando termina a execução de uma instrução, a unidade de controle tem acesso aos números que representam a próxima instrução a executar e segue fazendo esse ciclo enquanto tiver energia. Esse é o ciclo básico de funcionamento de um computador - busca, decodificação, execução.

Para que a unidade de controle (UC) tenha acesso aos números que codificam as instruções, eles são colocados na memória, na sequência em que devem ser executados. A primeira parte do ciclo de execução é chamado de busca porque a UC é quem vai buscar na memória cada instrução a ser executada. Esse conjunto de instruções é chamado de programa. Para que esse programa possa ser executado pela UC, os números devem representar instruções reconhecidas pela UC. O conjunto de códigos reconhecido pela UC é chamado de linguagem de máquina. Para que seja executado, um programa deve estar codificado corretamente nessa linguagem. Essa linguagem é definida em detalhes pelo fabricante da CPU.

A linguagem de máquina é uma linguagem extremamente detalhista (descreve cada mínima operação e transferência de dados que deve ser realizada na máquina), tipicamente qualquer programa útil contém milhares ou milhões de instruções. Esse nível de detalhe, além do fato de ser uma linguagem baseada em números, torna a tarefa de produzir um programa nessa linguagem bastante árdua. Para facilitar essa tarefa, criaram-se linguagens de programação de alto nível (a linguagem de máquina é de baixo nível) ou simplesmente linguagens de programação. Essas linguagens permitem descrever as operações que se quer que um computaor realize em um nível de abstração bem maior que o exigido pela linguagem de máquina, permitindo uma produtividade (e sanidade) bem maior. Mas a CPU continua só entendendo a sua linguagem de máquina, então para que um programa em uma linguagem de alto nível possa ser executado, ele deve ser traduzido. Por motivos históricos, essa tradução chama-se compilação, e é realizada por um programa chamado compilador. O desenvolvimento de um programa envolve então a escrita de um programa em uma linguagem de alto nível (é um texto, feito em um editor de textos), a compilação para produzir um programa equivalente em linguagem de máquina (que também é armazenado em um arquivo), e a execução, que envolve a transferência desse programa para a memória do computador e a sua execução pela unidade de controle; isso normalmente é controlado pelo sistema operacional, o programa que controla o funcionamento do computador.

Existem milhares de linguagens de programação de alto nível. Nesta disciplina, focaremos em uma delas, chamada C.

Forma básica de um programa C

Um programa em C é um texto, constituído, entre outras coisas, por um conjunto de seções (chamadas funções). Cada uma dessas seções tem, pelo menos, um nome, algo entre parênteses (que pode ser nada) e uma sequência (que pode ser vazia) de comandos, entre chaves. Um programa em C tem no mínimo uma função. Uma das funções é considerada a principal, e é onde o programa inicia sua execução. O nome dessa função tem que ser main. Então, o menor programa possível em C (que não faz nada), é o seguinte:

main(){}

Para que se possa executar um programa em C, ele deve ser traduzido para linguagem de máquina por um compilador. Em um computador, um programa (tanto em linguagem de alto nível como em linguagem de máquina) deve ser armazenado em um arquivo em disco. Por convenção, o nome de um arquivo que contenha um programa em linguagem C termina com os caracteres .c. Então, para que o programa acima possa ser executado, ele deve inicialmente ser colocado em um arquivo, usando um editor de textos. Em linux, pode-se utilizar por exemplo o editor chamado nano. Se quisermos colocar o texto acima em um arquivo chamado p1.c, poderíamos executar o comando abaixo.

nano p1.c

Em sistemas derivados de unix (como o linux), o programa executável equivalente é geralmente armazenado em um arquivo com o mesmo nome, sem o .c final. Um compilador bastante usual em sistemas linux chama-se gcc. Para executá-lo, deve-se informar qual o nome do arquivo que contém o programa a ser compilado e qual o nome do arquivo onde ele deve gravar o programa executável. A forma de se fazer isso em um comando no sistema linux é iniciar o comando por gcc e colocar os dois nomes, precedendo o nome do arquivo executável por -o. Podemos compilar o programa C armazenado no arquivo p1.c com o comando abaixo.

gcc p1.c -o p1

Pode-se escrever também assim:

gcc -o p1 p1.c

Finalmente, podemos executar o programa com o seguinte comando:

./p1

Esse programa não faz muito, precisamos de novos comandos para poder fazer um programa mais interessante. Em C, grande parte da funcionalidade é fornecida por módulos externos, e para poder utilizá-los é necessário informar o compilador. Por exemplo, para podermos utilizar comandos de entrada e saída, precisamos pedir ao compilador que inclua o módulo que contém esses comandos. A forma de se fazer isso é colocando-se, geralmente no início do programa, comandos #include, um por linha, um para cada módulo. O módulo de entrada e saída é chamado de stdio.h. Com a inclusão desse módulo, podemos usar o comando printf, que permite escrever textos em nosso terminal. Nosso programa pode ser aumentado com isso, e ficar assim:

#include <stdio.h>

main()
{
  printf("Oi.");
}

O nome do módulo a incluir deve estar entre os caracteres < e >. O que o comando printf deve imprimir tem que estar entre aspas duplas (") e entre parênteses. Os comandos em C são terminados por ponto-e-vírgula (;). Para ficar mais fácil de um humano ler um programa em C, normalmente se usa indentação – um comando que seja controlado outro é escrito mais à direita (com maior indentação) que o comando que o controla, como o comando printf, que está contido em main e por isso é precedido por espaços.

Se compilarmos e executarmos esse programa, ele irá imprimir os 3 símbolos que estão dentro das aspas. Dentro das aspas, pode-se colocar qualquer símbolo para ser impresso, exceto aspas ("), barra invertida (\) e o símbolo de porcentagem (%). A barra invertida é usada para a impressão de caracteres especiais. Para imprimir uma aspa, deve-se colocar \"; para imprimir a barra invertida, deve-se colocar \\. O porcento tem um uso diferente (veremos logo adiante), para imprimi-lo, deve-se colocar %%. Além desses caracteres, a barra invertida serve também para codificar outros caracteres, não visíveis, como final de linha (codificado com \n). Podes ver a lista completa, a motivação, e uma enormidade de outros detalhes sobre isso na wikipedia ou wikilivros ou alhures.

Pode-se colocar quantos comandos se quiser dentro de uma função como main. Eles serão executados um por vez, na ordem em que aparecem na função. Podemos fazer um programa que imprime um texto tão longo quanto se queira, usando vários comandos printf.

Faça programas para exercitar o uso de printf e dos comandos de edição, compilação, execução. Caso tenha dúvidas, envie mail para mim. Na próxima aula, vamos ver mais coisas que o printf pode fazer, e entender porque o % é especial.

Uso de um ambiente de programação online

Para evitar problemas com instalação / configuração de sistema, utilizaremos durante o período de suspensão das aulas presenciais um ambiente de programação em linha. Quem preferir, pode usar um ambiente linux.

Conecte-se a onlinegdb. Ele já vem com um programa C simples, semelhante ao visto em aula. Bem em cima da tela, à direita pode-se selecionar a linguagem (selecione C). No corpo principal da tela tem um editor de textos, onde se pode colocar o programa em C. Em cima, tem um botão verde, que compila e executa o programa. O resultado da compilação e o resultado da execução do programa aparecem na parte de baixo da tela.

Substitua o conteúdo do editor pelo programa visto acima, e execute seu programa. Faça testes com os caracteres de controle vistos, e com mais de um comando printf.

Formatação de dados com printf

O comando printf serve para imprimir mais coisas. O f no nome dele refere-se a “formatado”, porque ele pode formatar valores para impressão. A forma de funcionamento do printf é pegar cada caractere que encontra dentro das aspas e, se ele não representar um pedido de formatação, enviá-lo para a saída. Mas, se ele representar um pedido de formatação, ele identifica que formatação é essa, o valor que se deseja formatar, formata esse valor conforme o pedido e envia o resultado para a saída. Depois de realizar uma formatação, volta ao modo normal e continua com os caracteres seguintes, até encontrar outro pedido de formatação ou encontrar as aspas finais.

Um pedido de formatação sempre começa com o caractere %, e geralmente termina com uma letra. Essa letra vai dizer que tipo de formatação se quer realizar. Em geral, se formata um valor numérico para que ele seja impresso. Uma das formatações mais comuns é a formatação de um valor numérico inteiro em decimal. Essa formatação é representada pelos caracteres %d. O valor a ser formatado deve ser colocado dentro dos parênteses do comando printf, após as aspas, separado destas por uma vírgula. Por exemplo, para imprimir o valor 150, poderíamos ter o seguinte comando:

printf("%d", 150);

Não tem muita graça, poderíamos fazer a mesma coisa com

printf("150");

Fica mais interessante quando usarmos outras formas para obter o valor a ser impresso.

printf("%d", 2+3);

é diferente de

printf("2+3");

Teste para ver a diferença.

Um valor inteiro pode ser fornecido para o printf por uma constante inteira (como o 150) ou por uma expressão aritmética inteira (como o 2+3). Além do operador + para a soma, existem outros 4 operadores aritméticos inteiros em C: - para a subtração, * para a multiplicação, / para a divisão e % para o resto da divisão.

Altere seu programa para testar esses operadores. Note que são expressões inteiras, logo o resultado é sempre um número inteiro. Portanto, 11/4 vale 2, e 11%4 vale 3.

Como nas expressões matemáticas, os operadores multiplicativos têm precedência sobre os aditivos. As expressões 10+4*3 e 4*3+10 calculam o mesmo valor. Para forçar uma ordem, use parênteses (10+4)*3. Se precisar mais de um nível de parênteses, use parênteses (não pode usar colchetes nem chaves) ((10+4)*5+3)/2.

Um comando printf pode imprimir partes fixas e partes formatadas:

printf("O valor %d representa o seu resultado.\n", 10*(5/2));

Dá também para se formatar mais de um dado no mesmo comando. Os vários valores devem aparecer separados por vírgula, e são convertidos na ordem em que aparecem os pedidos de conversão.

printf("São dois valores: %d e %d.\n", 45, 60/4);

Exercícios

  1. Faça um programa para imprimir quantos segundos tem uma década.
  2. Faça outro que imprime aproximadamente quantos dias você já viveu.

Envie seu programa para calcular o número de segundos em uma década em um mail para benhur+l1@infufsm.br, com o assunto l1-e1-fulano, com fulano substuído pelo teu login na inf ou número de matrícula.

Variáveis

Com o que vimos até agora conseguimos fazer nosso computador imprimir texto, calcular e imprimir valores inteiros. O cálculo mais complexo de valores geralmente exige que se guarde valores intermediários, para tornar as expressões mais fáceis de escrever. Por exemplo, suponha que você precise calcular a área de um triângulo conhecendo os valores de seus lados. Uma forma que tem de fazer esse cálculo é pela fórmula de Heron, que envolve calcular o semiperímetro (metade da soma dos lados) e depois usar esse valor 4 vezes (tem que extrair a raiz quadrada de sp * (sp-a) * (sp-b) * (sp-c), onde sp é o semiperímetro e abc são os lados. Tente fazer esse cálculo usando o mesmo método anterior e verá que a expressão para o cálculo não só é complicada de se escrever como bastante fácil de se cometer um erro (supondo um triângulo com lados 2, 3 e 4, o semiperímetro seria (2+3+4)/2 e teríamos que calcular a raiz quadrada de uma expressão como (2+3+4)/2*((2+3+4)/2-2)*((2+3+4)/2-3)*((2+3+4)/2-4) Para simplificar esse tipo de expressão (e na verdade para se poder fazer qualquer programa um pouco mais complicado), precisamos dividir o trabalho em partes, guardando valores intermediários. É por isso, afinal, que um dos componentes mais importantes de um computador é a memória. Basicamente, o que precisamos é encontrar um lugar na memória do computador que não esteja sendo usado e usá-lo para guardar temporariamente os valores intermediários de que necessitamos. Quanto mais complexo é o programa que realizamos, em geral maior é o número desses lugares que necessitamos. Esse uso da memória deve ser organizado, ou rapidamente nosso programa vira uma bagunça. Para facilitar essa organização, precisamos de uma forma simples de distinguir entre as várias regiões de memória que nosso programa está utilizando. A forma mais comum de se fazer isso é dando um nome a cada uma dessas regiões. Uma região de memória com um nome é chamada de variável.

Em um programa em C, devemos informar as regiões de memória que necessitaremos, através de um comando chamado declaração de variável. Essa declaração serve para pedir ao compilador que encontre uma região de memória que esteja livre, e marque ela como estando, a partir de então, reservada para uso exclusivo para o nosso programa. Podemos declarar quantas variáveis quisermos, cada uma vai estar em uma região diferente. Cada uma deve ter um nome diferente. Além disso, o compilador precisa saber quanto de memória é necessário. Isso depende do que se quer colocar nessa variável. A linguagem C oferece algumas possibilidades sobre o que se pode colocar na memória, dependendo do que é oferecido pelo processador. A isso se chama tipo de dado. Um dos principais tipos de dados de C é para definir dados que terão valores inteiros. Esse tipo se chama int em C. O comando para se declarar uma variável precisa informar ao compilador essa duas informações, o tipo de dados que essa variável irá conter e o nome dela. Esse comando é tão comum que só tem isso. Por exemplo, o comando int a; declara uma variável chamada a, que pode guardar valores inteiros (ou do tipo int).

Existe um outro comando relacionado a variáveis que é o comando para se guardar um valor nela. Esse comando é chamado de atribuição. Atribui-se valores a variáveis. Por exemplo para atribuir-se o valor 5 à variável a, usa-se o comando a = 5;. O comando é um sinal de igual. À esquerda do sinal vai o nome da variável; à direita vai o valor que se quer colocar nessa variável. Depois da execução desse comando, a região de memória que foi reservada para a variável a passa a conter esse valor. O valor anterior que estava nessa posição é perdido. Uma variável só pode ter um valor por vez; esse valor é mantido até que seja atribuído um novo valor a essa mesma variável. Para obter o valor de uma variável, basta utilizar seu nome no local onde esse valor é necessário, em geral em uma expressão. Por exemplo, para imprimir o valor da variável a, pode-se usar o comando printf, como antes:

printf("%d", a);

ou, mais decorado:

printf("o valor calculado é %d\n", a);

Pode-se também, como antes, usar o valor de uma variável em uma expressão:

printf("o dobro do valor: %d\n", 2*a);

O comando de atribuição tem uma expressão à sua direita.

a = (5+12)*6;
b = a/4;

Apesar de usar o sinal de igual, o comando de atribuição não é uma afirmação. O segundo comando acima não está afirmando que o valor de b é igual a um quarto do valor de a. O que o comando está mandando o computador fazer é “pegue o valor que está na região de memória chamada a, divida esse valor por 4 e guarde o resultado na região de memória chamada b”. Quando um programa tem mais de um comando, como acima, esses comandos são executados um por vez, na ordem em que estão no programa. Se o valor de a for alterado depois da atribuição à variável b acima, o valor de b continuará o mesmo, e não será mais um quarto do valor de a. O valor de uma variável só é alterado por um comando de atribuição a essa variável. Por exemplo:

a = 10;
b = a*4;
a = 30;
b = a*4;

Tem dois comandos iguais, um vai colocar o valor 40 em b, o outro vai colocar o valor 120. O comando que coloca o valor 30 em a não afeta o valor 40 que tem em b. Uma variável só pode ser utilizada depois de ter sido declarada. O valor de uma variável só pode ser utilizado em uma expressão depois de ter havido um comando de atribuição para essa variável.

Exemplo de programa completo:

#include <stdio.h>

main()
{
  int lado;
  int area;

  lado = 4;
  area = lado*lado;

  printf("a area de um quadrado de lado %d é %d\n", lado, area);
}

Exercícios

  1. O que o programa abaixo vai imprimir? Responda sem usar o computador. Verifique sua resposta usando o computador (complete o programa com o que falta).
  a = 1;
  b = a+a;
  a = b+b;
  b = a+a;
  a = b+b;
  printf("%d %d\n", b, a);
  1. Mesma coisa
  a = 1;
  a = a+a;
  a = a+a;
  a = a+a;
  a = a+a;
  printf("%d\n", a);
  1. Mesma coisa de novo
  a = 1;
  b = 5;
  a = a + b;
  b = a - b;
  a = a - b;
  printf("%d %d\n", a, b);
  1. Refaça o exercício do número de segundos usando variáveis.

4abr 20h

Alguns detalhes que não foram discutidos acima.

Nomes de variáveis

Só podem fazer parte de um nome de uma variável em C um subconjunto dos caracteres disponíveis. São eles: as letras minúsculas (‘a’ a ‘z’), as letras maiúsculas (‘A’ a ‘Z’), os dígitos decimais (‘0’ a ‘9’) e sublinha (’_‘). O primeiro caractere de um nome não pode ser um dígito, e não deve ser um sublinha, porque nomes iniciados por sublinha estão reservados pela linguagem. Não pode-se ter duas variáveis diferentes com o mesmo nome, um nome identifica uma única variável, uma variável só tem um nome (mais tarde veremos exceções a essa regra). Alguns nomes são reservados pela linguagem e não podem ser usados (chamados de palavras reservadas). Em C, as letras minúsculas e as maiúsculas são consideradas diferentes, fazendo com que os nomes ’brasil’ e ‘Brasil’ sejam distintos. Por convenção, nomes de variáveis iniciam com minúscula, nomes iniciando com maiúscula geralmente designam valores constantes (que não são alterados durante a execução do programa). Note que o caractere espaço não é válido no nome de uma variável, assim como ponto, vírgula e qualquer que não esteja na lista acima.

Recentemente foi introduzida na linguagem C a possibilidade de se usar caracteres acentuados e caracteres não latinos (pode-se ter uma variável chamada Правда, por exemplo). Infelizmente não são todos os compiladores que suportam essa alteração. Essa alteração também pode produzir sérios problemas na leitura de um programa (como a distinção entre a segunda letra do exemplo acima e a letra ‘p’ minúscula), e na sua manutenção (imagina a dificuldade de alterar o programa com os nomes de variáveis em russo sem ter um teclado cirílico).

Valores inteiros

Valores inteiros (chamados de literais inteiros) em um programa C podem ser escritos de algumas formas diferentes. A mais comum é uma sequência de dígitos decimais, para representar um valor em decimal. Por exemplo, ‘42’ representa o valor quarenta e dois. Tem uma pegadinha, o primeiro dígito não pode ser ‘0’.

Se o primeiro dígito é ‘0’, seguido por uma sequência de dígitos entre ‘0’ e ‘7’, o número está em octal (base 8). O comando

  a = 011;

atribui o valor nove à variável a, não o valor onze.

Se o primeiro dígito é ‘0’, o segundo é ‘x’ ou ‘X’ e os demais são os dígitos decimais ou as letras ‘a’ a ‘f’ ou ‘A’ a ‘F’, o número está em hexadecimal (base 16). O comando

  a = 0x11;

atribui o valor dezessete à variável ‘a’.

Uma outra forma de representar um valor inteiro é com um caractere qualquer entre aspas simples. O valor representado é o valor do código inteiro usado pelo computador para representar esse símbolo internamente. Por exemplo,

   a = 'A';

coloca na variável a o valor usado internamente para representar o símbolo ‘A’. O valor do símbolo ‘A’ é 65, então o comando abaixo realiza exatamente a mesma operação que o comando acima:

   a = 65;

O comando abaixo vai armazenar em ‘a’ o valor do símbolo ‘B’ (que é 66):

   a = 'A' + 1;

Outras formatações de inteiros com printf

O printf pode ser usado para formatar valores inteiros de diversas formas. %d serve para formatar números inteiros em decimal, como já vimos. Outras formas:

Por exemplo, o que será impresso pelos comandos abaixo? (Faça um programa que contém esses comandos e descubra)

  int a;
  a = 'A';
  printf("O valor é %d\n", a);
  printf("O valor é %c\n", a);
  printf("O valor é %d\n", a+1);
  printf("O valor é %c\n", a+1);

Entrada de dados

Com o que vimos até agora, é possível usar o computador para realizar cálculos e imprimir os valores resultantes. Mas os dados que queremos que sejam calculados devem ser colocados no programa. Caso queiramos que o programa realize os mesmos cálculos sobre outros valores, teremos que alterar o programa, compilar e reexecutar para obter os novos valores.

Uma outra forma de se trabalhar com dados diferentes a cada vez é o programa receber os dados digitados pelo usuário enquanto está executando. Dessa forma, o programa pode ser usado para calcular usando valores diferentes a cada vez, sem precisar ser alterado ou compilado novamente. Para isso, precisamos de um comando de entrada de dados, que permita que o programa leia dados de um dispositivo de entrada (tipicamente o teclado).

O principal comando em C para entrada de dados pelo teclado é scanf. Ele é semelhante ao printf: tem, entre parênteses, instruções de como os dados devem ser convertidos (entre aspas) e onde esses dados devem ser colocados. Os pedidos de conversão são semelhantes aos do printf: %d é para a conversão de valores inteiros em decimal, por exemplo. Os locais onde o scanf deve colocar os valores convertidos são variáveis, e os nomes dessas variáveis devem ser precedidos pelo símbolo &.

Por exemplo, o comando abaixo espera que seja digitada uma sequência de dígitos do teclado, converte essa sequência no valor correspondente e armazena o resultado na variável a.

  scanf("%d", &a);

Abaixo está um programa mais completo, que pede para o usuário digitar um valor, e depois imprime o valor que foi digitado:

  #include <stdio.h>

  main()
  {
    int val;
    printf("Digite um número: ");
    scanf("%d", &val);
    printf("Você digitou o valor %d\n", val);
  }

Exercite esses comandos. Faça um programa para:

  1. ler um número digitado e imprime o dobro desse número;
  2. ler um número digitado e imprimir o quadrado desse número;
  3. ler um número digitado e imprimir o cubo desse número;
  4. ler dois números inteiros e imprimir a multiplicação entre eles;
  5. ler dois números que representam os valores dos catetos de um triângulo equilátero e imprimir um valor que corresponde ao quadrado da hipotenusa desse triângulo.

Atenção: o comando scanf só serve para entrada de dados. O que vai entre as aspas representa o que deve ser lido da entrada, nunca o que será escrito na saída. O comando scanf("abc"); lê um caractere da entrada e se for ‘a’, lê mais um e se esse segundo for um ‘b’ lê um terceiro, esperando que seja um ‘c’. Caso algum desses caracteres não seja o esperado, ele interrompe a leitura. O uso desse comando dessa forma é bastante restrito, para dizer o mínimo. Para nós, pelo menos por enquanto, o único caractere com alguma utilidade em um scanf, além das sequências começando por % é o caractere “espaço”, que significa “leia e ignore uma sequência de caracteres de espaçamento”, que podemos usar quando se tem uma sequência de valores a ser lidos por um único comando scanf. Por exemplo:

  scanf("%d %d", &var1, &b);

vai ler dois valores inteiros da entrada, e colocar o primeiro na variável var1 e o segundo na variável b. É comum um iniciante em C com alguma experiência em outra linguagem usar um comando como o abaixo:

  scanf("Digite um valor %d", &a);

O que esse comando faz não é escrever “Digite um valor” e depois esperar que o usuário digite algo. O que o comando realmente faz é esperar que o usuário digite “Digite um valor” seguido de um número, e aborta sua execução sem esperar o número e sem alterar o valor da variável a caso o usuário digite qualquer outra coisa. Moral da história: não coloque nada além de sequências de % dentro das aspas do scanf.

Valores de ponto flutuante

Além de poder trabalhar com números inteiros, o computador também suporta números de ponto (ou vírgula) flutuante. Eles são próximos dos números reais da matemática. Têm esse nome porque são implementados (aproximadamente) como um número inteiro e o número de posições que o ponto decimal (ou vírgula) deve ser deslocado, para a direita ou esquerda (o ponto “flutua” sobre o número). Por exemplo, o número 3.14 pode ser descrito como sendo 314 com o ponto deslocado para a esquerda dois dígitos. Usa-se dois números inteiros (314 e -2) para representar um número não inteiro.

O principal tipo de dados para representar um número de ponto flutuante em C é chamado float. Por exemplo, para declarar uma variável chamada a para receber valores em ponto flutuante, usa-se o comando:

  float a;

Para se escrever um valor em ponto flutuante dentro de um programa C, a forma mais simples é uma sequência de dígitos contendo um ponto decimal (não pode ser vírgula). O comando abaixo coloca o valor dois e meio na variável a:

  a = 2.5;

Usa-se os mesmos símbolos que usamos com inteiros para as 4 operações matemáticas (não tem a operação resto da divisão (%) com números em ponto flutuante):

  a = 2.5*(4.2+25.0)/12.2;

Para que o comando printf imprima um valor em ponto flutuante, usa-se o formato %f. Por exemplo, compilando e executando o programa abaixo

#include <stdio.h>

main()
{
  float a;
  a = 2.5*(4.2+25.0)/12.2;
  printf("Valor resultante: %f\n", a);
}

será impresso

Valor resultante: 5.983606

O %f sozinho sempre usa 6 casas decimais após a vírgula. Pode-se mudar isso, por exemplo %.2f pede uma formatação com duas casas após a vírgula.

A leitura de um valor para uma variável em ponto flutuante também se faz com scanf, com o formato %f:

  float salario, aumento;
  printf("Digite o valor do salário e o percentual de aumento: ");
  scanf("%f %f", &salario, &aumento);
  float novo_salario;
  novo_salario = salario + salario * aumento / 100.0;
  printf("O salário aumentado de %.1f%% será %.2f.\n", aumento, novo_salario);

Exercícios

  1. O trecho de programa acima tem 4 caracteres . Qual a função de cada um?
  2. Faça um programa C que lê o percurso percorrido por um carro em km e o número de litros de combustível que foram queimados para percorrer esse trecho, e informa o consumo médio do carro, em km/l.
  3. Complemente o programa anterior, lendo também o valor do litro em reais e imprimindo o custo do trecho em reais e o custo médio em reais por km.
  4. Faça um programa que lê o consumo do carro em km/l para etanol e para gasolina, o custo do litro do etanol e da gasolina, e a distância que se quer percorrer, e informa o custo da viagem, em reais, para cada combustível.

Limitações nos valores representáveis

Os valores, tanto inteiros como de ponto flutuante, são armazenados na memória do computador com um número limitado de dígitos. Esse limite impõe limites aos valores que são representáveis pelos tipos de dados da linguagem C. A linguagem C não impõe exatamente esses limites, para que seja mais simples de portar essa linguagem para máquinas de capacidades diferentes. Na maioria das máquinas atuais, são usados 32 dígitos binários (bits) tanto para dados do tipo int quanto float. Com 32 bits, é possível representar 232 (pouco mais de 4 bilhões) valores diferentes. Esses 4 bilhões de possibilidades são usados de forma diferente em int e em float. Em ambos os casos, metade dos valores são usados para representar números negativos, e a outra metade para os não negativos (o zero está nessa segunda metade), resultando em aproximadamente dois bilhões de valores positivos e outro tanto de negativos.

Para int, a escolha dos valores a representar é bem simples, são os 2 bilhões de valores mais próximos do zero, ou mais especificamente, todos os valores inteiros entre -2147483648 e +2147483647 (tem um valor positivo a menos porque o zero está nesse grupo). Isso quer dizer que valores fora desses limites simplesmente não são representáveis por uma variável do tipo int. Caso uma operação resulte em um valor não representável, diz-se que ocorreu um “estouro” (overflow em inglês). Erros desse tipo geralmente não interrompem a execução dos programas, apenas produzem valores errados. A regra de que valores são produzidos é clara: ao passar do maior valor representável, chega-se ao menor valor representável. Para testar, faça um programa que imprime o valor de 2147483647+1 (e +2 e +3, para entender a lógica). A mesma lógica se aplica para valores negativos (-2147483640-10 dá quanto?).

Caso os implementadores da linguagem tivessem escolhido 64 bits e não 32 bits para representar um int, o problema continuaria a existir, mas estaria bem mais longe (com 64 bits, pode-se representar todos os números positivos até 9223372036854775807). Em compensação, aumentaria a quantidade de memória necessária para armazenar cada número, e o tempo para realizar cada operação sobre esses números. E nem sempre se necessita trabalhar com números tão grandes. A forma que os idealizadores da maior parte das linguagens de computação resolveram esse problema foi de repassá-lo ao programador, e C não é exceção. O programador é o responsável por indicar a quantidade de memória (e os valores representáveis) para cada variável, e com que precisão (e tempo) será calculada cada sub-expressão do seu programa. Se algo não der certo porque a escolha não foi a mais adequada, o erro é diretamente atribuível ao programador, isentando a máquina.

A forma de se fazer isso é definindo vários tipos de dados diferentes para representar valores inteiros, para as diversas representações possíveis de valores inteiros possibilitados pela máquina. Em C, esses tipos são, em ordem não decrescente de tamanho:

  1. char
  2. short ou short int
  3. int
  4. long ou long int
  5. long long ou long long int

Os tamanhos precisos não são definidos pela linguagem (!), somente que essa ordem não pode ser invertida, e que um char tem 8 bits. Dá para saber o tamanho de um tipo de dados na implementação que se está usando com o operador sizeof. Por exemplo, para saber quantos bytes tem um long, pode-se usar o comando:

  printf("número de bytes em um long: %d\n", sizeof(long));

No computador+compilador que estou usando agora, os 5 tipos acima são implementados com 1, 2, 4, 8 e 8 bytes, respectivamente. Isso faz com que os limites de valores que um dado desses tipos podem representar são:

  1. char: -128 a 127
  2. short: -32768 a 32767
  3. int: -2147483648 a 2147483647
  4. long e long long: -9223372036854775808 a 9223372036854775807

Pra imprimir em decimal valores desses tipos usando printf, usa-se o formato %d para int e todos os tipos menores, %ld para dados do tipo long e %lld para dados do tipo long long.

No caso de valores em ponto flutuante, é um pouco mais difícil de se saber os valores que são representáveis, e em geral se usa uma aproximação. O tipo ‘float’ geralmente usa 32 bits, 24 deles para representar a mantissa e 8 para representar o quanto a vírgula vai ser deslocada. Como é uma representação binária, o deslocamento da vírgula é sobre dígitos binários. Com 8 bits, pode-se deslocar a vírgula 127 bits para um lado ou para outro, o que corresponde aproximadamente a 40 dígitos decimais. Com 24 bits, consegue-se representar qualquer número inteiro com 6 dígitos decimais (e alguns com 7). Então, pode-se representar com um dado do tipo ‘float’ valores com 6 dígitos decimais de precisão, desde 10-38 até 10+38. Isso quer dizer que valores surpreendentemente baixos não são representáveis precisamente em variáveis do tipo float. Por exemplo, o valor 20000001 tem 8 dígitos decimais de precisão e não é representável precisamente em uma variável do tipo float. Para comprovar, faça um programa que coloca esse valor em uma variável do tipo float e depois imprime o valor dessa variável. Qual o próximo valor inteiro não representável precisamente em float?

Uma outra limitação é que valores representável de forma exata com um pequena número de dígitos em decimal podem não ser representáveis de forma exata em binário. Por exemplo, faça um programa que coloca o valor um décimo (0.1) em uma variável float e imprime com 20 casas decimais. Repita com double. Esse problema não é exclusivo de representação binária, o valor 1/3, por exemplo, não é representável de forma exata em decimal mas tem uma representação bem simples em base 3 (0.1).

Para valores em ponto flutuante com maior precisão, a linguagem C oferece o tipo double, geralmente implementado com 64 bits, que tem o equivalente a aproximadamente 16 dígitos decimais de precisão, podendo representar valores tão pequenos como 10-308 ou grandes como 10308. Para formatar valores desse tipo, use %lf no scanf ou printf.

Tipagem das operações

A linguagem C permite que se misture dados de tipos diferentes em expressões numéricas. As operações aritméticas realizadas pela máquina, no entanto, são feitas em um tipo único: a instrução que soma dois números int produz um resultado int. Isso pode levar a erros fáceis de cometer e por vezes difíceis de serem encontrados. Por exemplo, para calcular a média de dois números, pode-se usar o programa abaixo:

  #include <stdio.h>

  main() {
    int n1, n2, m;
    n1 = 7;
    n2 = 10;
    m = (n1 + n2) / 2;
    printf("A média entre %d e %d é %d\n", n1, n2, m);
  }

Execute o programa. Ops! O resultado não parece certo. A média pode não ser um número inteiro, então usamos uma variável do tipo float para armazenar essa média:

  #include <stdio.h>

  main() {
    int n1, n2;
    float m;
    n1 = 7;
    n2 = 10;
    m = (n1 + n2) / 2;
    printf("A média entre %d e %d é %f\n", n1, n2, m);
  }

Execute esse programa. Scheiße! Por que será que ainda não deu certo?

O problema é a forma como o C decide que tipo de dados utilizar em uma operação. Essa decisão é tomada a cada operação, baseada somente nos operandos dessa operação. No caso da expressão que calcula a média ((n1 + n2) / 2), a primeira operação que será realizada é a soma n1+n2. Os dois operandos da soma são inteiros, então a soma será inteira, e o resultado será inteiro. A segunda operação realizada é a divisão, que é entre o resultado da operação anterior (que é inteiro) e o valor 2, também inteiro. Logo, a divisão será realizada em inteiro, com resultado inteiro. A terceira operação é a atribuição desse resultado à variável m. o resultado é inteiro, a variável é float, ele converte o valor do resultado para float e realiza o armazenamento. Só que o resultado nunca teve a parte decimal, então não tem como essa parte ser recuperada, e o que é impresso é um número com a parte decimal zerada.

Para que funcione como se espera, temos que fazer com que a divisão seja realizada em ponto flutuante. Uma forma de fazer isso seria usar float para todas as variáveis:

  #include <stdio.h>

  main() {
    float n1, n2, m;
    n1 = 7;
    n2 = 10;
    m = (n1 + n2) / 2;
    printf("A média entre %f e %f é %f\n", n1, n2, m);
  }

Uma outra forma é conhecer as regras de “promoção de tipos” em C. Funciona assim: a cada operação, o compilador analisa o tipo dos operandos. Se algum for inteiro menor que int (char ou short), é convertido para int. Depois disso, se os dois tipos são iguais, a operação e o resultado serão desse tipo. Se forem diferentes, um dos valores é convertido para o tipo do outro, e a operação e o resultado serão desse último tipo. A conversão é sempre o valor do tipo que aparece antes ser convertido no tipo que aparece depois na ordem int, long, float, double. Então, se um for float e o outro for int, o valor inteiro será convertido para float e a operação será realizada em float, com resultado float. O programa poderia também ter sido alterado para forçar somente a divisão a ser feita em float, por exemplo assim:

  #include <stdio.h>

  main() {
    int n1, n2;
    float m;
    n1 = 7;
    n2 = 10;
    m = (n1 + n2) / 2.0;
    printf("A média entre %d e %d é %f\n", n1, n2, m);
  }

Exercícios:

  1. Use as 4 versões do programa acima para calcular a média entre um bilhão (1000000000) e dez (10). Explique o resultado obtido em cada uma delas (quando não for o esperado).
  2. Faça um programa que pede para o usuário digitar 3 números, todos inteiros, e calcula e imprime a média entre eles.

Comando de seleção if

Um programa deve ser capaz de tomar decisões, de acordo com os dados que está manipulando. A forma mais simples de decisão é a execução condicional, em que um trecho de programa é ou não executado dependendo dos valores dos dados. Em C esse comando é if. A forma básica do comando if é:

  if (condição) {
    comandos
  }

Ou seja, é a palavra if seguida de uma condição entre parênteses, seguido de comandos entre chaves. A execução do comando if é: verifica a condição, que pode ser verdadeira ou falsa; se for falsa, os comandos entre chaves são ignorados, e o comando if termina; se a condição for verdadeira, os comandos entre chaves são executados. Os comandos entre chaves são uma sequência de quaisquer comandos da linguagem. A condição entre parênteses é uma expressão que produz um valor lógico. Um valor lógico pode ser verdadeiro ou falso, e é produzido por operadores de comparação ou por operadores lógicos.

Operadores de comparação

A linguagem C tem 6 operadores de comparação, que comparam dois operandos numéricos e produzem um valor lógico. São eles:

Os operadores que têm dois símbolos devem ser escritos com esses símbolos unidos (sem espaço entre eles), e na ordem mostrada (é <= e não =<). Muito cuidado com o operador de igualdade (==), para não confundir com o de atribuição (=).

Exemplo de um programa com if:

#include <stdio.h>

int main()
{
  int a, b;
  printf("Digite dois números, o primeiro maior que o segundo.\n");
  scanf("%d %d", &b, &a);
  if (b<=a) {
    printf("Humano idiota!\n");
  }
  return 0;
}

Exercício

  1. Refaça o programa que calcula o valor de uma viagem com gasolina e com álcool, para que o programa indique qual combustível é mais barato.

Comando if com cláusula else

O comando if, além de ser usado para execução condicional, pode também ser usado para seleção simples. Nesse caso, ao invés de selecionar se um trecho de código vai ou não ser executado, ele sempre executa um entre dois trechos de código que ele comanda. Se o resultado da expressão for verdadeiro ele executa o primeiro trecho, se for falso, executa o segundo. O segundo trecho de código é informado em uma cláusula else.

if (expressão) {
  comandos V
} else {
  comandos F
}

Complementando o exemplo anterior:

#include <stdio.h>

int main()
{
  int a, b;
  printf("Digite dois números, o primeiro maior que o segundo.\n");
  scanf("%d %d", &a, &b);
  if (a>b) {
    printf("Bom garoto!\n");
  } else {
    printf("Humano idiota!\n");
    printf("Te dou mais uma chance.\n");
    scanf("%d %d", &a, &b);
    if (a>b) {
      printf("Ufa!\n");
    } else {
      printf("Desisto!\n");
    }
  }
  return 0;
}

Exercícios

Faça um programa que:

  1. Lê dois números, imprime qual o maior.
  2. Lê dois números, imprime “sao iguais” ou imprime qual o menor.
  3. Lê um número, diz se é par ou ímpar (use o resto da divisão por 2 par decidir).
  4. Lê um número, e se ele for igual à senha secreta, lê um segundo número e imprime se ele é igual, menor ou maior que a senha super secreta. Se o primeiro número for diferente da senha secreta, imprime “acesso negado”. A senha secreta é 27 e a senha super secreta é 462.
  5. Lê 3 números, imprime qual o maior.
  6. Lê 3 números, imprime qual o do meio.
  7. Lê 2 números entre 0 e 10, que representam as notas de um aluno, e mais dois números, que representam os pesos de cada nota. Se a média ponderada das notas for pelo menos 7, informa que o aluno está aprovado. Se não for, lê mais um número, que é a nota do exame. Se a média entre o exame e a média anterior for pelo menos 5, informa que o aluno está aprovado. Senão, informa que o aluno reprovou.
  8. Lê 3 números que representam os ângulos de um triângulo e diz se

Comandos if aninhados para seleção múltipla

As vezes queremos que nosso programa selecione um trecho a executar, entre várias possibilidades. Por exemplo, no cálculo do imposto de renda, de acordo com o valor considerado se entra em uma ou outra faixa; exatamente uma das faixas de imposto deve ser selecionada, entre as várias possíveis. Uma forma usual de se implementar uma seleção desse tipo, chamada de seleção múltipla, é usando vários comandos if, da seguinte forma: Coloca-se um comando if com um teste que define precisamente uma das opções. Os comandos controlados por esse teste são os comandos a serem executados para essa opção. No else desse if estão todas as demais opções. Nesse else, coloca-se um if que testa exatamente a segunda opção (podendo valer-se da informação que o primeiro if falhou). O código nesse segundo if é o código correspondente à segunda opção; o else dele será executado para todas as demais opções e assim por diante até que o último else coresponderá à última opção, sem necessitar de teste, já que todos os if anteriores falharam. Uma forma comum de se formatar esse aninhamento é indentando-se todos os if com o mesmo nível de indentação, e não usar chaves para o else (o que é possível quando se tem só um comando controlado, no caso o próximo if) para destacar que o grupo de comandos está atuando de forma conjunta:

  if (teste1) {
    comandos correspondentes ao teste1
  } else if (teste2) {
    comandos correspondentes ao teste2
  } else if (teste3) {
    comandos correspondentes ao teste3
  //...
  } else {
    comandos correspondentes à última opção, que não é testada
  }

Operadores lógicos

Os operadores lógicos permitem se gerar valores lógicos a partir de outros valores lógicos. A linguagem C possui 3 operadores lógicos, dois deles combinam dois valores lógicos para produzir um resultado (são operadores binários) e o outro opera sobre um único valor lógico para produzir seu resultado (é um operador unário). São eles:

Operadores e valores lógicos são também chamados de “booleanos”, em homenagem a George Boole.

Por exemplo, no Brasil o voto é obrigatório, mas não para todos. É obrigatório para quem tem 18 anos ou mais, mas não é obrigatório para quem tem 70 anos ou mais. Então, se quero fazer um teste para decidir, a partir da idade, se uma pessoa é obrigada a votar, só uma comparação não é suficiente. Pode-se fazer o teste desta forma:

  if (idade >= 18) {
    if (idade < 70) {
      printf("voto obrigatório\n");
    }
  }

Ou seja, o segundo if só vai ser testado se o primeiro foi bem sucedido; o printf só vai ser executado se ambos if forem bem sucedidos. Esse mesmo teste pode ser realizado de forma mais sucinta com o operador de conjunção &&:

  if (idade >= 18 && idade < 70) {
    printf("voto obrigatório");
  }

Em outras palavras, o voto é obrigatório se a idade for 18 ou mais E, ao mesmo tempo, for inferior a 70. Só ser superior a 18 não basta, nem sò ser inferior a 70; são necessárias as duas condições ao mesmo tempo. Por isso o operador &&.

E se o teste fosse o contrário, imprimir algo para quem não é obrigado a votar? Não é obrigado a votar se for menor de idade ou tiver 70 anos ou mais. Nesse caso, só um dos testes é suficiente, se tiver menos de 18 já se sabe que não é obrigado a votar. Se tiver 70 ou mais também. Basta um dos testes para que se decida; usa-se o operador OU ||.

  if (idade < 18 || idade >= 70) {
    printf("não é obrigado votar");
  }

Como esses dois testes são um o contrário do outro, poderia ser escrito também usando o operador de negação para inverter o primeiro teste:

  if (!(idade >= 18 && idade < 70)) {
    printf("não é obrigado votar");
  }

Além de mais sucinto, tem uma outra vantagem em se usar um só if nesses casos: se fosse necessário executar códigos diferentes para o caso do voto obrigatório ou não, basta colocar uma cláusula else; na solução com os dois if fica um tanto mais complicado.

Atenção: em matemática, pode-se escrever algo como 10 < x < 20 para testar se um número está entre 10 e 20. Em C isso não funciona, essa expressão quer dizer : compara 10 com x, se for menor produza verdadeiro senão produza falso; compare então esse valor (V ou F) com 20 (o que vai dar sempre verdadeiro, porque em C falso “vale” 0 e verdadeiro “vale” 1). O correto em C para saber se x está entre 10 e 20 é x > 10 && x < 20.

Exercícios

  1. Os testes de votantes acima estão incompletos, porque ainda tem o caso de quem não pode votar por ter menos de 16 anos. Complete os testes, e use comandos if aninhados, para escrever que a pessoa não pode votar, pode mas não é obrigada ou que é obrigada a votar.
  2. Um ano é bissexto quando é múltiplo de 4, exceto os anos que são múltiplos de 100, que em geral não são bissextos, a não ser que sejam também múltiplos de 400. Faça um programa que lê um número que corresponde ao ano e imprime se ele é ou não bissexto. Implemente com vários comandos if, sem usar operadores booleanos e reimplemente com um só if e operadores booleanos. Teste pelo menos os anos 2020 (S), 2018 (N), 2000 (S), 1900 (N).
  3. Faça um programa que lê a massa e altura de uma pessoa e calcula seu IMC. Use comandos if aninhados para imprimir a classificação da pessoa (abaixo do peso, peso normal, etc).

O tipo de dados bool

Os valores lógicos, que podem ser verdadeiros ou falsos e são produzidos por um operador de comparação ou por um operador lógico, têm um tipo próprio, que é o tipo bool. Esse é um tipo que tem somente dois valores, tão poucos que têm nomes, false e true. Pode-se ter variáveis desse tipo, e em algumas situações elas podem tornar o código mais legível. Por exemplo, em um dos exercícios foi pedido para se calcular se um ano era bissexto ou não. A expressão que decide se um ano é ou não bissexto não é exatamente simples, então o código que contém essa expressão pode não ser muito legível. Poderia ficar um pouco mais fácil de ler com uma variável para representar a situação, com um nome mais claro:

  // em vez de 
  if (tralha pra identificar se é bissexto) {
    faz alguma coisa que só deve ser feita se é bissexto
  }

  // poderia ser
  bool bissexto;
  bissexto = tralha pra identificar se é bissexto
  if (bissexto) {
    faz alguma coisa que só deve ser feita se é bissexto
  }

O if, pelo menos, fica mais fácil de ser lido e de se saber o que está acontecendo, mesmo sem um comentário. Para se poder usar essas 3 palavras (bool, false e true) em um programa, deve-se incluir stdbool.h no início desse programa.

Mistura de valores lógicos e numéricos

Da mesma forma que o C aceita que se misture valores de tipos inteiros e de ponto flutuante em uma mesma expressão, ele também permite que valores lógicos sejam também misturados. Os valores são convertidos automaticamente de um tipo para outro, conforme a necessidade. Se a conversão for de um tipo numérico para o tipo bool, o valor será convertido em false caso sea 0 e em true caso seja qualquer outra coisa. No caso de um valor bool ser convertido em um valor numérico, será convertido para 0 se for false e para 1 se for true.

Então, por exemplo, o comando

if (a) {
  C1;
}

com a sendo uma variável inteira, irá executar o comando C1 se o valor da variável a for diferente de zero.

  c = a * (a>b);

com a, b e c inteiros, vai colocar em c o valor de a se ele for maior que b, ou 0 se não for. Em algumas (poucas) situações isso pode produzir código que pode ser considerado mais simples; nas demais, isso piora a legibilidade ou torna mais fácil de erros passarem desapercebidos ou mais difícil de erros serem encontrados.

Comando de seleção switch

A linguagem C tem um outro comando de seleção múltipla, que é o switch. É um comando mais restrito que o if: enquanto o comando if decide se um comando é ou não executado a partir do resultado de uma expressão lógica que pode realizar cálculos relativamente complexos, o comando switch realiza a decisão baseado em um único valor inteiro. Esse valor inteiro é comparado a valores constantes presentes no corpo do comando switch, e se existir um valor igual, a seleção é feita.

O formato do comando switch é:

switch (expressão) {
  comandos
}

A expressão é uma expressão numérica que produz um valor inteiro. comandos é uma sequência de quaisquer comandos da linguagem C. Além dos comandos “normais” da linguagem, essa sequência pode também conter comandos break, cuja execução causa o fim da execução do comando switch. Além dos comandos, pode existir um número qualquer de cláusulas case. Cada cláusula case tem o formato:

  case valor:

ou seja, a palavra case seguida e um valor inteiro constante, seguida do caractere :. Não podem existir duas cláusulas case com o mesmo valor. Além das cláusulas case, pode existir no máximo uma cláusula default:, que é a palavra default seguida por :. Essas cláusulas servem para definir o primeiro comando a ser executado pelo comando switch. Caso o valor de alguma cláusula case seja igual ao valor da expressão do switch, o primeiro comando a ser executado será o primeiro comando seguinte a essa cláusula. Caso nenhum valor de cláusula case seja igual à expressão e exista uma cláusula default, o primeiro comando a ser executado será o primeiro comando após a cláusula default. Caso não exista valor de case igual ao da expressão e não exista cláusula default, o comando switch termina sem executar nenhum de seus comandos.

Exemplo:

  ds =  dias_desde_domingo % 7;
  printf("Hoje é ");
  switch (ds) {
    case 0:
      printf("domingo");
      break;
    case 1:
      printf("segunda-feira");
      break;
    case 2:
      printf("terça-feira");
      break;
    case 3:
      printf("quarta-feira");
      break;
    case 4:
      printf("quinta-feira");
      break;
    case 5:
      printf("sexta-feira");
      break;
    case 6:
      printf("sábado");
      break;
    default:
      printf("o dia que o computador não funciona");
  }
  printf(". Aproveite.\n");

Pode-se ter vários cases para selecionar o mesmo código:

  ds =  dias_desde_domingo % 7;
  printf("Hoje é ");
  switch (ds) {
    case 0:
    case 6:
      printf("fim de semana");
      break;
    case 1:
    case 2:
    case 3:
    case 4:
      printf("dia de semana");
      break;
    case 5:
      printf("sexta-feira");
      break;
  }

As cláusulas case não são comandos, e não são executadas. Um erro comum é esquecer de colocar o break achando que só porque se chegou ao final dos comandos selecionados por um case o switch vai acabar, mas isso não acontece, o case só seleciona o primeiro comando a ser executado; a execução prossegue até ser encontrado o comando break ou terminarem os comandos do switch. Por exemplo, o trecho programa abaixo vai imprimir “abacaxi” se x for 5, e “caxi” se for 4.

  switch (x) {
    case 1:
      printf("manga");
      break;
    case 5:
      printf("aba");
    case 4:
      printf("caxi");
      break;
    case 3:
      printf("invalido");
      break;
  }

Que será impresso pelo código abaixo? E se o a for 4? E se for 5? Pense nas respostas antes de colocá-lo em um programa para conferir.

  int a=6;
  int b=3;
  int x=0;
  switch ((a+b)%3) {
    case 2:
      x=1;
    case 1:
      x=2;
      printf("2");
      break;
    case 0:
      x=3;
  }
  printf("%d\n", x);

Funções

Conforme nossos programas vão ficando mais complexos, eles também vão ficando maiores e mais difíceis de serem lidos e entendidos (e escritos). Uma das formas de se lidar com essa complexidade é a modularização. Separa-se o programa em módulos, que têm finalidade definida, a implementação de uma parte do que o programa deve realizar. Esses módulos podem ser desenvolvidos e testados separadamente, simplificando a tarefa, tanto do desenvolvimento quanto do teste. Um módulo recebe um nome, geralmente que evidencia o que o módulo faz. Quando em um trecho do programa se necessita que determinado módulo seja executado, basta colocar seu nome.

Um módulo em C é chamado de função. Comandos da linguagem só podem existir dentro de módulos (ou de funções). Um programa em C tem no mínimo uma função, chamada main. Quando um programa está executando é uma das funções desse programa que está em execução. Para que uma função do programa seja executada, essa execução deve ser comandada por outra função – se diz que uma função é chamada por outra. Quando uma função termina sua execução, ela retorna e a execução continua coma função que a chamou. A função main é especial porque ela é a primeira a ser executada pelo programa, ela é chamada pelo sistema operacional. Quando essa função retorna, o programa termina sua execução. A chamada de uma função em C é realizada colocando-se o nome da função seguido de parênteses. Se um programa tem uma função chamada ident, ela pode ser chamada com o comando

  ident();

A definição de uma função, é realizada como já viemos fazendo com a função main:

  nome()
  {
    comandos
  }

ou seja, o nome da função, seguido de parênteses, seguido de comandos entre chaves. Por exemplo, a função ident (que servirá para identificar o programa) poderia ser escrita como:

ident()
{
  printf("Programa X\n");
  printf("versão 0.1 beta\n");
  printf("\n");
  printf("por Fulano\n\n");
}

Essa função poderia ser chamada no início da execução do programa, em main, deixando a função main mais limpa:

int main()
{
  ident();
  // segue o resto do programa
  return 0;
}

Uma função pode ser parametrizada, ou seja, ela pode ter seu comportamento alterado dependendo de parâmetros que são passados a ela pela função chamadora. Um parâmetro é uma (ou mais) variável da função, que tem tem seu valor inicial atribuído de uma forma especial na hora que a função é chamada. Essa forma de atribuição é chamada de passagem de parâmetros ou passagem de argumentos. Na declaração da função, as variáveis que vão receber os parâmetros são declaradas dentro dos parênteses que seguem o nome da função. Na chamada da função, os valores que serão atribuídos a essas variáveis são colocados dentro dos parênteses logo após o nome da função que está sendo chamada. A execução de uma função envolve a criação dessas variáveis, a atribuição dos valores a elas e só então a função inicia sua execução. Quando a função termina, essas variáveis são destruídas (a memória utilizada por elas é liberada para ser reutilizada). Por exemplo, a função ident acima poderia ser parametrizada com o número da versão do programa. Vamos chamar esse parâmetro de versao, do tipo float. Agora, em vez de imprimir sempre 0.1 como versão do programa, ela receberá esse número como argumento:

ident(float versao)
{
  printf("Programa X\n");
  printf("versão %.1f beta\n", versao);
  printf("\n");
  printf("por Fulano\n\n");
}

A chamada da função agora deve passar o argumento para ela:

int main()
{
  ident(0.1);
  // segue o resto do programa
  return 0;
}

Uma função pode também produzir um valor. Tipicamente esse valor é produzido por alguma manipulação dos argumentos que a função recebe. Nesse caso, diz-se que a função retorna o valor que ela produz para a função que a chamou. Para identificar que valor é retornado, a função usa o comando return seguido pelo valor que deve ser retornado. Esse valor tem que ter um tipo, e esse tipo é informado na definição da função, logo antes do nome dela. Por exemplo, se quisermos fazer uma função que calcula e retorna o cubo do número que ela recebe como argumento, do tipo float, poderíamos definir essa função dessa forma:

float cubo(float numero)
{
  return numero * numero * numero;
}

A função se chama cubo, ela recebe como parâmetro um float que será colocado em uma variável chamada numero, e durante sua execução ela calcula o cubo desse número e retorna o valor calculado. A função que chama cubo deve passar como argumento um valor cujo cubo ela quer saber, e obter o valor retornado. O valor retornado é disponibilizado na função chamadora como se a função chamada fosse uma variável, o nome da função chamada pode ser usado em uma expressão e será substituído pelo valor retornado. Por exemplo, na função chamadora, poderíamos ter:

  float x;
  x = cubo(1.5);

Esse comando colocaria o cubo de 1.5 na variável x. Poderia ser uma expressão mais complexa:

  float a;
  float x;
  scanf("%f", &a);
  x = cubo(a)*2;
  printf("o dobro do cubo de %f é %f\n", a, x)

O valor passado como parâmetro também pode ser calculado por uma expressão qualquer:

  float a;
  float x;
  scanf("%f", &a);
  x = cubo(a/2)*2;
  printf("o dobro do cubo da metade de %f é %f\n", a, x)

Um programa pode ter tantas funções quantas o programador quiser. Expressões podem ser compostas por várias chamadas de funções. Expressões que calculam valores de parâmetros também. Por exemplo, se além da função que calcula o cubo o programa tivesse também uma função que calcula a metade e outra que calcula o dobro, o cálculo de x acima poderia ser:

  x = dobro(cubo(metade(a)));

Escreva as funções dobro e metade coloque tudo num programa que calcula o dobro do cubo da metade do valor digitado. Faça o programa imprimir também o dobro do cubo vezes a metade desse mesmo valor digitado (se o valor for 4, deve calcular o cubo de quatro (64) multiplicado pela metade de 4 (64*2=128) e imprimir o dobro disso (256), mas não deve calcular diretamente, deve chamar as funções). Coloque a definição das funções auxiliares antes da definição da função main.

A função main é a única que é executada automaticamente; as demais funções de um programa só serão executadas se forem chamadas por outra função (durante a execução dessa outra função). Uma função pode ser chamada por main ou por outra função que por sua vez seja chamada por main e assim por diante. Uma função pode (e deve) usar outras funções para facilitar sua vida. Por exemplo, a função cubo acima poderia fazer uso de uma outra função que calcula o quadrado:

float cubo(float n)
{
  float res;
  res = quadrado(n) * n;
  return res;
}

A linguagem C tem uma rica biblioteca de funções á desenvolvidas e disponíveis para serem usadas. Na verdade, já temos usado pelo menos duas dessas funções, printf e scanf não são comandos da linguagem, são funções que foram escritas em C e estão disp[onibilizadas em uma biblioteca de funções (um arquivo que contém um conunto de funções) chamada entrada e saída padrão (stdio). A inclusão de stdio.h no início dos nossos programas serve para que o compilador tenha acesso a essas funções. Tem uma descrição sucinta da biblioteca padrão do C na wikipedia. Uma parte dessa biblioteca contém funções matemáticas, como raiz quadrada, potência, logaritmo, funções trigonométricas. Para ter acesso a elas tem que incluir math.h.

Por exemplo, a função que calcula a raiz quadrada se chama sqrt():

#include <stdio.h>
#include <math.h>

int main()
{
  double x;
  printf("Digite um número: ");
  scanf("%lf", &x);
  printf("A raiz quadrada desse número é %lf\n", sqrt(x));
  return 0;
}

Uma função pode ter mais de um argumento (parâmetro). Nesse caso, os argumentos são colocados entre os parênteses separados por vírgula, e os valores a serem passados para esses parâmetros na hora da chamada da função devem estar na mesma ordem em que aparecem na definição da função. Por exemplo, poderíamos fazer uma função que calcula a hipotenusa de um triângulo retângulo, recebendo como argumentos os valores dos sois catetos:

double quadrado(double x)
{
  return x*x;
}

double hipotenusa(double cateto1, double cateto2)
{
  return sqrt(quadrado(cateto1) + quadrado(cateto2));
}

Exercício para entregar

Faça um programa que lê 3 valores em ponto flutuante (a, b, c), que representam os coeficientes da equação de segundo grau a x^2^ + b x + c = 0. o coeficiente a não pode ser zero – o programa deve testar isso e abortar (informando o usuário) caso não esteja ok. O programa deve então calcular e apresentar as raízes da equação, usando a fórmula de Bāskhara II para isso. O programa deve ter uma função para calcular o discriminante delta. O programa deve separar os 3 casos:

Para ficar mais completo, faça uma função que calcula o valor da função de 2º grau, recebendo a, c, b, x. Use essa função para verificar se as raízes calculadas estão corretas.

Todos devem enviar um mail para benhur+l1@inf.ufsm.br, antes da próxima terça-feira. Esse mail pode (ou não) conter o programa, e deve ter comentário sobre se o assunto até aqui está fácil e podemos prosseguir (ou acelerar), ou se tá indo razoavelmente bem, ou se tá muito complicado (de preferência explicitando o que tá pior) ou o que mais quiser dizer.

Exemplo de implementação doprograma

#include <stdio.h>
#include <math.h>

// calculo de delta
float delta(float a, float b, float c)
{
  return b*b - 4*a*c;
}

// cálculo da função de 2o grau no ponto x
float eq2g(float a, float b, float c, float x)
{
  return a*x*x + b*x + c;
}

// funcao principal
int main(void)
{
  // consegue os coeficientes
  float a, b, c;
  printf("Digite os coeficientes a, b, c da eq 2o grau ax2 + bx + c = 0\n");
  scanf("%f", &a);
  if (a == 0) {
    printf("o coeficiente a não pode ser 0\n");
    return 1;
  }
  scanf("%f%f", &b, &c);

  // calcula delta
  float d = delta(a, b, c);

  // dependendo do delta, decide se tem 0, 1 ou 2 raizes reais
  // sao 3 casos excludentes, típico para uma solução com ifs aninhados
  if (d < 0) {
    printf("raízes não são reais\n");
  } else if (d == 0) {
    // só tem uma raiz
    float x;
    x = -b/(2*a);
    printf("Uma raiz real: %f\n", x);
    printf("Valor da equação na raiz: %f\n", eq2g(a, b, c, x));
  } else {
    // duas raizes
    float x1, x2;
    x1 = (-b+sqrt(d))/(2*a);
    x2 = (-b-sqrt(d))/(2*a);
    printf("Duas raizes reais: %f e %f\n", x1, x2);
    printf("Valor da equação na raiz 1: %f\n", eq2g(a, b, c, x1));
    printf("Valor da equação na raiz 2: %f\n", eq2g(a, b, c, x2));
  }

  return 0;
}

Mais detalhes sobre funções

Uma função pode ter um número qualquer de parâmetros, inclusive nenhum. Para deixar mais explícito para o compilador que a função não tem nenhum parâmetro, usa-se a palavra void entre os parênteses, na definição da função; na chamada da função, usa-se os parênteses vazios.

Uma função pode não retornar nenhum valor. Nesse caso usa-se a palavra void antes do nome da função, na definição dela; na chamada, não se usa nada, mas uma função void não pode participar de uma expressão, afinal ela não tem valor.

Uma função que não retorna valor (função void) pode não ter o comando return. Nesse caso, a função termina sua execução e retorna para a função chamadora quando a execução chegar em seu fecha chave final. Caso se queira que a função retorne antes, ou se queira deixar mais bem documentado que se chegou ao final da função, ela pode ter um comando return sem valor.

No caso de funções que retornam valor, o uso do comando return é obrigatório. Pode ter vários comandos return no corpo da função; o primeiro que é executado encerra sua execução. A execução de uma função que retorna valor não pode chegar ao final da função.

Para exemplificar uma função com o uso de vários comandos return, poderíamos implementar uma função que decide se um ano é bissexto, com vários testes:

  bool bissexto(int ano)
  {
    if (ano % 4 != 0) {
      return false;  // anos não múltiplos de 4 não são bissextos
    }
    // só chega aqui se for múltiplo de 4
    if (ano % 400 == 0) {
      return true; // anos múltiplos de 400 são bissextos
    }
    // a execução só chega neste ponto se o ano for múltiplo de 4 e não de 400
    if (ano % 100 == 0) {
      return false; // múltiplos de 100 que não são de 400 não são bissextos
    }
    // se todos os ifs anteriores falharam, o ano é bissexto
    return true;
  }

Exemplo de função void (a função não precisa retornar nada a quem chama, ela so imprime (ou nao uma mensagem):

void imprime_se_positivo(int v)
{
  if (v > 0) {
    printf("%d", v);
  }
}

Exemplo de função que não tem parâmetros:

int le_numero_positivo(void)
{
  printf("Digite um número positivo: ");
  int v;
  scanf("%d", &v);
  if (v < 0) {
    v = -v;
    printf("vou usar %d\n", v);
  }
  return v;
}

Variáveis e funções

As variáveis que são declaradas dentro de uma função (nos parênteses ou dentro das chaves) pertencem à função e não podem ser acessadas por outras funções. São chamadas variáveis locais à função ou simplesmente variáveis locais. Variáveis de funções distintas são independentes entre si, e inclusive podem ter o mesmo nome. Por exemplo:

int f(int a) {
  int x;

  x = a; // copia o valor recebido para x
  a = 6; // altera a variavel local, destruindo o valor recebido
  y = x; // invalido, a função f nao tem variavel y
  return a;
}

void g(void) {
  int y;
  int a;
  a = 3;
  y = 2;
  y = f(a); // copia o valor do a da funcao g para o a da funcao f
  printf("x=%d\n", x); // erro, g nao tem x
  printf("a=%d\n", a); // deve imprimir 3
  printf("y=%d\n", y); // deve imprimir 6
}

Comandos de repetição

Os programas que vimos até agora são compostos por uma sequência de comandos, que são executados um após o outro. Cada um desses comandos, depois de traduzidos para linguagem de máquina, tipicamente ocupam alguns bytes da memória do computador. Um computador atual pode então armazenar alguns bilhões desses comandos em sua memória. A velocidade de um computador é tal que ele consegue executar alguns bilhões de instruções por segundo. Então, se fizermos um programa grande o suficiente para ocupar toda a memória do computador, em questão de segundos ele seria totalmente executado. Mas não é isso que se observa. Nem os programas são tão grandes como raramente permanecem tão pouco tempo em execução. Qual o segredo? Os programas geralmente estão re-executando as mesmas instruções. O que muda são os dados. Por exemplo, considere um editor de textos. O que ele tem que fazer é organizar em memória todos os caracteres que forem digitados. O que tem que ser feito com cada caractere é mais ou menos a mesma coisa que com os caracteres que o precederam, mas a cada vez com um caractere diferente.

Para que isso seja possível, necessitamos de comandos que permitam repetir sequencias de código. A linguagem C tem 3 comandos para isso. Todos têm o mesmo poder de expressão, daria para ter só um deles. Mas se identificou situações um pouco diferentes de se controlar a repetição, e se criou comandos específicos que tornam a codificação dessas situações mais adequada.

Comando while

O primeiro comando de repetição que veremos é o comando while. O formato dele é semelhante ao formato do comando if sem else:

while (expressão) {
  comandos
}

Da mesma forma que o if, a expressão é uma expressão que produz um valor lógico. Da mesma forma que o if, se o valor dessa expressão for verdadeiro, os comandos serão executados, e se for falso a execução do comando while termina. A diferença é o que é feito após a execução dos comandos. No caso do if, termina a execução do if. No caso do while, ele começa tudo de novo, recalculando a expressão e executando ou não os comandos. Esse ciclo se repete enquanto a expressão produzir o valor verdadeiro.

Exemplo:

int v;

v=1;
while (v < 10) {
  printf("%d\n", v);
  v = v + 1;
}

Esse trecho de programa inicializa a variável v em 1 e depois, se esse valor for menor que 10 (e é), imprime o valor e altera a variável para ter o valor seguinte. Depois disso, volta a testar (agora v vale 2, que ainda é menos que 10). O programa segue imprimindo números em ordem enquanto o valor de v for menor de 10. O último valor impresso será 9, porque depois de imprimir 9, v será alterado para conter o valor 10, e na repetição seguinte o teste vai falhar e o comando while termina.

Um cuidado que tem que ser tomado é garantir que as repetições (os “laços”) terminem. Para isso, algum dos comandos no interior do while deve alterar algum valor que participa do teste que controla a repetição. No caso do exemplo, o teste envolve a variável v, e essa variável é alterada no interior do laço. Só alterar não basta, tem que alterar de alguma forma que o teste falhe após certo número de repetições. Se o teste fosse v > 0, o while não teria fim e teríamos uma situação chamada de laço infinito, que é algo a ser evitado em um programa. (Na verdade, devido às limitações e a forma como um int funciona, nesse caso o laço não seria realmente infinito, porque quando v chegasse ao maior valor possível, o próximo valor seria o menor possível, e o teste falharia).

Exercícios

  1. Faça um programa para imprimir todos os números inteiros positivos menores que 20.
  2. Faça um programa para imprimir todos os inteiros pares positivos menores que 20.
  3. Faça um programa para imprimir todos os inteiros ímpares positivos menores que um número digitado. Peça para o usuário redigitar até que número digitado seja um número que fará o programa imprimir pelo menos 1 número.
  4. Faça um programa que imprime todos os números inteiros positivos menores que a raiz quadrada do número digitado pelo usuário. Não pode usar a função sqrt.
  5. Faça um programa que imprime o maior inteiro positivo menor que a raiz quadrada do número digitado. É o último número impresso pelo programa anterior. Ainda não pode usar sqrt.
  6. Faça um programa que imprime todos os anos bissextos entre o ano inicial e final digitados. Use a função bissexto acima (ou escreva outra).
  7. Altere o programa anterior para, em vez de informar quais, informar somente quantos anos bissextos existem.

Comando do ... while

Semelhante ao coando anterior, o comando do ... while tem o seguinte formato:

do {
  comandos
} while(expressão);

A forma de funcionamento também é semelhante, ele vai repetir a execução dos comandos que ele controla enquanto a expressão for verdadeira. A diferença é que o teste da expressão só é feito no final do laço, após a execução dos comandos. Isso quer dizer que os comandos serão executados pelo menos uma vez. Um uso bem comum desse comando é para verificar uma entrada de dados:

int v;
do {
  printf("digite um valor positivo para v: ");
  scanf("%d", &v);
} while(v < 1);

Comando for

O terceiro comando de repetição da linguagem C é o comando for. Ele tem o seguinte formato:

for (inicialização; condição; incremento) {
  comandos
}

O funcionamento dele é o seguinte: inicialmente é executada a inicialiazação. Então é avaliada a condição. Se a condição for falsa, o comando for termina. Se a condição for verdadeira, os comandos são executados. Após a execução dos comandos, é executado o incremento e volta a repetir a partir da avaliação da condição.

Tipicamente o comando for é usado quando se conhece o número de repetições que se quer executar. Por exemplo, para executar o comando printf abaixo 20 vezes, o código mais comum é:

int i;
for (i=0; i<20; i=i+1) {
  printf(".");
}

Se diz nesse caso que o comando for está sendo controlado pela variável i, chamada variável de controle. Esse caso é tão comum que pode-se declarar a variável de controle dentro do próprio comando for, e ela é destruída quando o comando for acaba:

for (int i=0; i<20; i=i+1) {
  printf(".");
}
// aqui a variável i não existe mais

É bastante comum se precisar saber em qual das repetições se está. Para isso, acessa-se o valor da variável de controle nos comandos controlados pelo for. No exemplo acima, na primeira execução o valor de i é zero, na segunda é 1, na vigésima é 19. Por exemplo, pode-se usar esse valor para imprimir:

for (int i=0; i<20; i=i+1) {
  printf("%d ", i*10);
}
// aqui a variável i não existe mais

Apesar de permitido, não é recomendável alterar o valor da variável de controle dentro do laço.

Comandos adicionais de controle de repetição

Existem dois comandos que auxiliam o controle de laços de repetição, e podem ser usados entre os comandos controlados por um comando de repetição:

Esses comandos geralmente estão dentro de um comando if.

Exercícios (números interessantes)

  1. O número 1233 tem uma característica interessante: 122 + 332 = 1233. Será que existem outros números que têm essa característica? Faça uma função que recebe um número inteiro e retorna um bool, que é true caso esse número tenha essa característica e false caso contrário. Use essa função em um laço para testar todos os números não negativos menores que 10000 e imprimir aqueles que têm essa mesma característica.
int main()
{
    for (int i=0; i<10000; i++) {
        if (interessante(i)) {
            printf("%d\n", i);
        }
    }
}
  1. Repita, para o caso de números como 2025 = (20+25)2.
  2. Repita, para o caso de números menores de 1000 como 371 = 33 + 73 + 13.

Exercícios (impressão de triângulos)

Faça uma função que recebe um inteiro e um char, e imprime tantas cópias desse caractere. Por exemplo, chamando a função com tantoschar(10, '-'); ela vai imprimir ----------. Use essa função para implementar os programas abaixo.

  1. Faça um programa que usa essa função e que lê um número do usuário e imprime tantas linhas, cada uma com um número crescente de asteriscos. Por exemplo, se o usuário digitar 4, o programa deve imprimir
*
**
***
****
  1. Repita o programa anterior, mas nas linhas pares imprima a linha com hifens:
*
--
***
----
  1. Repita, mas invertendo:
----
***
--
*
  1. De novo. Dica: cada linha é composta por tantos espaços e tantos asteriscos, chama a função duas vezes.
   *
  **
 ***
****
   *
  ***
 *****
*******
  1. Dica: 2 laços.
   *
  ***
 *****
*******
 *****
  ***
   *
  1. Dica: cria outra função, pra fazer a linha com asteriscos nas pontas.
   *
  *-*
 *---*
*-----*
 *---*
  *-*
   *
----*----
---* *---
--*   *--
-*     *-
--*   *--
---* *---
----*----

Exercícios (números primos)

Um número primo é um número inteiro positivo que não é múltiplo de nenhum inteiro positivo maior que 1 e menor que ele próprio. Faça uma função que recebe um inteiro e retorna true ou false para o caso de ele ser primo ou não. Use essa função para implementar os programas descritos abaixo.

  1. Imprime todos os números primos menores que um número digitado pelo usuário.
  2. Imprime os primeiros n números primos (o valor de n é informado peo usuário).
  3. Imprime a soma dos n primeiros primos.
  4. Imprime os fatores primos de um número digitado pelo usuário. Dica: uma função que recebe um número e retorna o próximo primo maior que o número recebido pode ser útil. Faça o programa calcular também o produtório desses fatores para verificar que está correto (deve ser igual ao número digitado).
  5. Faça uma função int mmc(int a, int b), que calcula e retorna o mínimo múltiplo comum entre dois números. Faça um programa para testar essa função. Pode refrescar a memória sobre mmc e mdc aqui.
  6. Faça uma função int mdc(int a, int b), que calcula e retorna o máximo divisor comum entre dois números. Faça um programa para testar essa função.

Vetores

A forma de representar valores em nosso programa é através de variáveis. Uma variável tem um tipo de dados e pode conter um valor desse tipo (por vez). Quando se atribui um valor à variável, o valor antigo é perdido. Se necessitamos guardar vários valores em nosso programa, precisamos de várias variáveis, uma para cada valor. Como cada variável tem que ser declarada no programa e deve ter um nome distinto das demais, temos que saber com antecedência quantos valores o nosso programa vai utilizar. Para alguns tipos de programas, isso pode ser muito restritivo.

Suponha um programa que deve ler um número desconhecido de valores e calcular a média entre esses valores. Para o cálculo da média, necessitamos dois valores, o somatório dos valores dos quais se quersaber a média e o número desses valores. Podemos ter uma variável para cada um deles, iniciamos elas em 0, e para cada valor lido, somamos o valor no total e incrementamos o contador de valores. No final, podemos calcular a média sem problemas. Em forma de programa (digamos que se marque o final dos valores de entrada com um número negativo):

  int s = 0;
  int n = 0;
  for (;;) {
    int v;
    scanf("%d", &v);
    if (v<0) {
      break;
    }
    s = s+v;
    n = n+1;
  }
  media = s/n;

Facinho. Agora suponha que se deseja saber quantos dos valores estão abaixo da média. Mais fácil ainda:

  int c = 0;
  for (;;) {
    int v;
    scanf("%d", &v);
    if (v<0) {
      break;
    }
    if (v < media) {
      c++;
    }
  }
  // a variavel c tem o numero de valores abaixo da media

Só tem um porém: tem que ter um valor correto na variável media, e para obter esse valor, necessita-se todos os valores da entrada. Uma forma de resolver o problema seria pedir para o usuário digitar tudo de novo (não seria a melhor forma de contentar o usuário). Outra forma seria guardar os valores para reprocessá-los. Só que não sabemos quantos valores são, e mesmo que soubéssemos, só seria viável fazer o programa para um número bem pequeno de valores.

Necessitamos de uma forma de poder guardar vários valores, sem precisar de uma variável para cada um. Um vetor é exatamente isso. É uma variável que permite o armazenamento de vários valores independentes entre si. Tem a restrição de que cada valor tem que ter o mesmo tipo, mas para o nosso problema, é bem o caso.

Em C, a forma de se declarar um vetor é semelhante à declaração de uma variável simples, acrescida do número de valores que queremos que o vetor tenha, entre colchetes. Por exemplo, para declarar um vetor chamado a, capaz de conter 50 valores inteiros, fazemos assim:

  int a[50];

Para acessar um dos valores do vetor, dizemos qual deles queremos colocando o seu indice entre colchetes. Indice é a posição no vetor, em um vetor de N posições, temos índices desde 0 até N-1, para identificar cada posição. Em qualquer lugar de um programa onde se pode usar uma variável normal de um determinado tipo, pode-se usar um elemento de um vetor. Por exemplo:

  a[0] = 30;
  x = a[20];
  a[2] = a[4] - y;
  scanf("%d", &a[6]);
  printf("%d\n", a[3]);

O índice pode ser fornecido por uma constante inteira (como nos exemplos acima), ou por qualquer expressão da linguagem que produza um valor inteiro, por exemplo:

  i = 0;
  a[i] = 20;
  for (j=0; j<10; j++) {  // copia da posicao 10-19 para 0-9
    a[j] = a[j+10];
  }

O exemplo acima, de se calcular quantos dos valores digitados estão abaixo da média desses valores poderia ser escrito assim:

#include <stdio.h>
#include <stdbool.h>


int main()
{
  float soma, media;
  float valores[100];
  int n_total, n_abaixo;
  
  n_total = 0;
  while (n_total < 100) {  // le no maximo 100 valores
    float v;
    scanf("%f", &v);
    if (v<0) {  // valor negativo indica fim dos dados
        break;
    }
    valores[n_total] = v;
    n_total++;
  }

  soma = 0;
  for (int i=0; i<n_total; i++) {
    soma += valores[i];
  }
  if (n_total <= 0) {
    printf("não sei calcular a média de zero valores!\n");
    return 1;
  }
  media = soma/n_total;
  printf("media: %f\n", media);

  n_abaixo = 0;
  for (int i=0; i<n_total; i++) {
    if (valores[i] < media) {
      n_abaixo++;
    }
  }
  printf("%d valores estão abaixo da média.\n", n_abaixo);
  return 0;
}

A linguagem C não faz verificação de limites de um vetor, é responsabilidade do programador garantir que seu programa não faz acesso a um índice inválido (menor que 0 ou maior que N-1). Essa é uma das principais fontes de erro em programas C.

Em C, não existe atribuição de vetores, somente de elementos de vetor. Por exemplo, para copiar o vetor b para o vetor a abaixo, tem que fazer um laço que copie elemento a elemento.

  int a[30];
  int b[30];
  //... coloca valores em a
  b = a; -> comando inválido, nao existe atribuição de vetores
  for (int i=0; i<30; i++) {  // para copiar um vetor, copia-se cada valor
    b[i] = a[i];
  }

Exercícios

  1. Faça um programa que lê dez números e os imprime na ordem inversa à que foram lidos.

  2. Altere seu programa de fatoração (exercício sobre números primos) para colocar os fatores em um vetor. Depois, verifica que os fatores estão corretos (se o produto deles é igual ao valor fatorado) e, caso positivo, imprime os fatores.

  3. Faça um programa que lê dois vetores de 5 inteiros cada e depois copia os valores do primeiro vetor para as primeiras 5 posições de um terceiro vetor e os valores do segundo vetor para as posições finais desse terceiro vetor, de 10 posições. O programa deve imprimir os 3 vetores no final.

  4. Repita o exercício anterior, mas copiando os elementos alternadamente de cada vetor (se os dois primeiros vetores forem 1 2 3 4 5 e 5 4 3 2 1, o terceiro vetor deve ser 1 5 2 4 3 3 4 2 5 1).

  5. Repita o exercício anterior, mas copiando os dados do segundo vetor do fim para o início (se forem 1 2 3 4 5 e 6 7 8 9 0, o terceiro será 1 0 2 9 3 8 4 7 5 6).

  6. A função rand (inclua <stdlib.h>) produz um número inteiro aleatório (na verdade, pseudo-aleatório). Pode-se usar ela para se fazer uma função que funciona como um dado (produz um número entre 1 e 6 cada vez que é chamada):

    int dado(void) {
      return rand() % 6 + 1;
    }

    Faça um programa que testa se essa função faz um bom dado, com probabilidades semelhantes de cair cada um dos valores. Lance o dado um número alto de vezes e imprima quantas vezes caiu cada valor possível. Use um vetor de 6 posições para os contadores.

  7. Altere o programa anterior para calcular e imprimir um “fator de desonestidade” do dado, definido como a diferença entre o número de vezes que cai o número que cai mais vezes e o número de vezes que cai o número que cai menos vezes dividido pelo número de lançamentos.

  8. Faça um programa que preenche um vetor com 100 números aleatórios, cada um entre 0 e 99. Depois, o programa deve dizer qual foi o maior e o menor número gerado.

  9. Altere o programa anterior para informar, além do maior e menor números, a posição da primeira ocorrência de cada um deles.

  10. Altere o programa anterior para informar quantas vezes ocorreu cada número.

  11. Altere o programa anterior para informar qual o número que ocorreu mais vezes.

Matrizes

Uma matriz é uma forma de organizar dados semelhante a um vetor. A diferença é que os dados são organizados em linhas e colunas. Como em um vetor, todos os dados são do mesmo tipo. A declaração de uma matriz com 3 linhas, cada linha contendo 4 valores de ponto flutuante em precisão dupla é feita assim:

  double xis[3][4];

Como em um vetor, os índices começam em zero: para zerar o elemento que está na segunda coluna da primeira linha da matriz declarada acima, usa-se um comando como xis[0][1] = 0;; para ler um valor para a posição na última coluna da última linha: scanf("%lf", &xis[2][3]); Para ler toda a matriz, poderíamos usar algo como:

  for (int linha=0; linha<3; linha++) {
    printf("Digite os valores da %da. linha\n", linha+1);
    for (int coluna=0; coluna<4; coluna++) {
      printf("coluna %d: ", coluna);
      scanf("%lf", &xis[linha][coluna]);
    }
  }

A linguagem não está limitada a só duas dimensões. Pode-se ter matrizes com qualquer número de dimensões, colocando-se mais colchetes. A limitação é o tamanho da memória.

Vetores como argumentos de funções

Quando se faz uma chamada a uma função que recebe argumentos, tem-se uma atribuição implícita, do valor passado à variável local da função que recebe o argumento. A linguagem C não tem atribuição de vetores, então não seria possível passar um vetor como argumento para uma função. O que se fez foi dizer que o nome de um vetor, diferente dos demais tipos de dados, não corresponde ao valor do vetor, mas à uma referência ao vetor. A partir dessa referência, pode-se acessar o vetor. Na prática, isso quer dizer que quando passamos um vetor para uma função, a função consegue alterar o vetor da função chamadora, a variável que é o argumento é uma espécie de “apelido” para a variável original.

A forma de se declarar uma função que recebe um vetor como argumento é como a declaração de um vetor, mas geralmente se deixa vazio o interior dos colchetes:

  int f(double v[])
  {
    //...
  }

Declara a função f como uma função que recebe um vetor de double e retorna um int. Para chamar essa função, poderíamos ter:

  double x[20];
  //...
  a = f(x);

Na chamada, o nome do vetor vai dentro dos parênteses da função, sem colchetes. Nesse caso, durante essa execução de f, sua variável v é um sinônimo para a variável x da função chamadora. Toda alteração que f fizer em v será na verdade uma alteração em x.

Quando uma função recebe um vetor, ela não tem como saber o tamanho dele (o número de elementos que ele contém). Algumas soluções para esse problema:

O primeiro caso é o mais simples, mas o mais restritivo (tipicamente é usado em programas em que tem todos os vetores do mesmo tamanho, ou o tamanho é ligado a algo que não varia (o número de dias na semana, por exemplo)).

O segundo caso é bastante usado em C para o armazenamento de cadeias de caracteres, com um caractere especial para representar o final (veremos isso logo).

O terceiro caso é o que vamos usar agora. O recomendado (embora não obrigatoriamente seja o mais comum) é colocar o tamanho logo antes do vetor na lista de argumentos, para se poder usar este estilo:

  int f(int n, double vet[n])

para ficar auto-documentado que o parâmetro n contém o tamanho do vetor vet.

Por exemplo, uma função para ler um vetor de inteiros poderia ser escrita assim:

  void le_vetor(int n, int v[n])
  {
    printf("Digite %d valores inteiros ");
    for (int i=0; i<n; i++) {
      scanf("%d", &v[i]);
    }
  }

Essa função poderia ser chamada assim:

  int dados[10];
  le_vet(10, dados);

Exercícios

  1. Refaça os programas anteriores, usando funções para realizar as operações básicas sobre os vetores. Por exemplo, no programa 1 crie uma função para ler o vetor, outra para inverter os valores no vetor e outra para imprimir o vetor. O programa principal ficaria assim:

      int main()
      {
     int d[10];
     le_vet(10, d);
     inverte_vet(10, d);
     imprime_vet(10, d);
     return 0;
      }

    No segundo, pode ter uma função para fatorar. Ela recebe o número e um vetor, coloca os fatores no vetor e retorna o número de fatores. Uma outra função pode calcular o produtório de um vetor.

  2. Faça uma função que recebe um vetor de inteiros e dois índices, e troca o valor que está em um dos índices pelo que está no outro. Por exemplo, se o vetor v tem os valores 1 2 5 4 3 6, depois de chamar a função troca(v, 2, 4);, o vetor passará a conter 1 2 3 4 5 6. Faça um programa para testar essa função.

  3. Faça uma função que recebe um vetor de inteiros e dois inteiros que representam índices nesse vetor. Esses índices limitam uma região do vetor (o primeiro é o índice inicial dessa região e o segundo é o índice final). A função deve retornar o índice onde se encontra o menor valor na região delimitada do vetor. Por exemplo, se o vetor v contém 1 2 7 6 5 8 3, a chamada pos_menor(v, 2, 5) deve retornar 4 (4 é o índice onde está o valor 5, o menor entre 7, 6, 5, 8). Se existir o menor valor em mais de uma posição, qualquer das oposições pode ser retornada.

  4. Faça uma função que recebe um vetor de inteiros e o tamanho do vetor, e ordena os valores no vetor em ordem crescente. Para ordenar, faça o seguinte: em cada posição do vetor, começando pela primeira, use a função do exercício anterior para encontrar a posição onde está o menor elemento, desde essa posição até o final do vetor. Esse é o elemento que deve estar na posição considerada. Use a função de troca do outro exercício para trocar esse elemento com o que está agora na posição.

    Por exemplo, suponha que inicialmente o vetor tenha 2 3 1 4. A primeira posição é 0. Chamando a função pos_menor(v, 0, 3), retorna 2, que é a posição onde está o menor número. Chamando troca(v, 0, 2), para trocar o menor número para a posição 0, o vetor fica 1 3 2 4. A próxima posição é 1. Chamando agora pos_menor(v, 1, 3), retorna 2, que é a posição onde está o menor número a partir da posição 1 (o número 2). Chamando troca(v, 1, 2), o vetor torna-se 1 2 3 4. A próxima posição é 2. Chamando pos_menor(v, 2, 3) retorna 2. Chamando troca(v, 2, 2) não muda o vetor. Não precisa testar a última posição, porque não vai ter com quem trocar, então o único valor que sobrou certamente já está na posição certa.

Inicialização de vetores

O comando de atribuição não permite que a atribuição seja feita a um vetor. Para copiar os valores de um vetor para outro deve-se copiar cada elemento. Há uma excessão, que é na declaração do vetor. Nessa hora, o vetor pode ser inicializado com um valor inicial para cada elemento, colocando-se os valores separados por vírgula, entre chaves:

  int v[5] = {6, 5, 7, 9, 2};

Caso tenha menos valores que o tamanho do vetor, os demais valores serão inicializados em 0. Pode-se omitir o tamanho do vetor, nesse caso o vetor será criado com o tamanho necessário para conter todos os elementos da lista de inicialização. Não pode ter mais elementos na lista de inicialização que o número de elementos do vetor.

No caso de matrizes, cada dimensão tem que estar em um nível diferente de chavez, com as chaves do nível inferior separadas por vírgulas:

  int m[2][3] = { { 1, 2, 3 }, { 4, 3, 2 } };

No caso de vetores de char, eles podem ser inicializados com uma sequência de caracteres entre aspas, cada caractere será colocado em uma posição do vetor. O restante do vetor será preenchido com caracteres de código 0:

  char mensagem[30] = "Feliz aniversario";
  // Os primeiros 17 caracteres do vetor serão preenchidos com os caracteres
  // entre aspas, os outros 3 com zeros.

Cadeias de caracteres - strings

Uma cadeia de caracteres (ou string, em inglês) é um tipo de dados usado para se armazenas sequências de caracteres, de forma a se poder trabalhar com palavras, frases, ou quaisquer outras sequências de caracteres em nossos programas. A linguagem C não oferece strings como um tipo básico da linguagem, sendo usados vetores de char como local de armazenamento dessas sequências e funções para manipulá-las.

A forma que se padronizou para o armazenamento de strings no interior de vetores foi colocar um caractere especial para representar o final da string, logo após seu último caractere. Todas as funções de biblioteca que tratam com strings em C seguem essa convenção. O caractere especial é o de código 0, que portanto não pode ser representado no interior de uma string. Para armazenar uma string com n caracteres, necessita-se de um vetor com capacidade para no mínimo n+1 caracteres, para ter onde colocar esse caractere delimitador.

A linguagem C fornece algumas poucas facilidades para o uso de strings nos programas. Uma delas é a inicialização de vetores de char com a notação de sequência de caracteres entre aspas, visto acima. Outra é a possibilidade de se colocar uma sequência de caracteres entre aspas em qualquer lugar onde se pode usar o nome de um vetor, representando um vetor anônimo, não alterável, inicializado com a string correspondente aos caracteres entre as aspas. Já usamos isso em funções como printf, por exemplo, que recebe um vetor de char (armazenando uma string) como seu primeiro argumento.

Falando em printf, essa função tem suporte à escrita de strings, com o formato %s. A função scanf também tem suporte a leitura de strings, como o mesmo formato. Por exemplo:

  char nome[20];
  printf("Qual seu nome? ");
  scanf("%s", nome);
  printf("Oi, %s! Espero que esteja tudo bem com você.\n", nome);

Note que no caso do scanf não vai o caractere & antes do nome da variável a ler. Isso é porque a variável que receberá a string é um vetor, e a passagem de vetores para funções é diferente dos demais tipos, a função recebe uma referência ao vetor, e pode alterá-lo.

Ainda no caso do scanf, a leitura de strings com %s funciona da seguinte forma: os caracteres de espaçamento (espaços, tabulações, fim de linha) são desprezados até aparecer um caracteres que não seja de espaçamento; a sequência de caracteres que não são de espaçamento é lida e colocada no vetor, até que seja lido um caractere de espaçamento; esse último não é colocado no vetor, o scanf termina a string no vetor colocando um caractere 0. Não é portanto possível ler sequências de caracteres que contenham espaço.

Existe um outro formato de leitura de strings para o scanf que não tem esse comportamento especial com caracteres de espaçamento, o formato %[]. Dentro dos colchetes coloca-se um conjunto de caracteres que podem ser aceitos pelo scanf. Ele lê caracteres da entrada, e enquanto eles estiverem nesse conjunto, adiciona-os à string. Caso o primeiro caractere dentro dos colchetes seja ^, os demais caracteres representam o conjunto de caracteres que não devem ser colocados na string, e delimitam a entrada. Por exemplo, o formato %[^\n] significa leia para a string todos os caracteres que encontrar até o fim da linha (\n). Em qualquer dos formatos, pode-se limitar o número máximo de caracteres a ser lido para o vetor, com um número logo após o %. O formato %10[^\n] causa a leitura de dez caracteres ou até o final da linha, o que vier primeiro.

A biblioteca padrão da linguagem tem várias funções para manipulação de strings, incluindo-se <string.h>. Algumas delas:

Por exemplo, o código abaixo deve escrever "abacaxi" tem 7 letras

  char a[10];
  char b[10] = "caxi";

  strcpy(a, "aba"); // copia a string para a
  strcat(a, b);     // concatena b no final de a

  int t = strlen(a);
  printf("\"%s\" tem %d letras\n", a, t);

A função minha_strlen abaixo é uma implementação possível para strlen:

int minha_strlen(char v[])
{
  int tam;
  tam = 0;
  for (int i=0; v[i] != '\0'; i++) {
    tam++;
  }
  return tam;
}

Exercícios

  1. Implemente suas versões das funções strcpy, strcat e strcmp. Faça um programa para testá-las.

  2. Implemente uma função que recebe uma string e remove espaços repetidos nessa string. Por exemplo, se a string recebida pela função for

    "uma frase  cheia   de    espacos"

    a função deve alterar essa string para que fique

    "uma frase cheia de espacos"
  3. A maioria das linguagens de programação não permite que nomes definidos pelo programador (variáveis, funções, etc) contenham espaços, mas não é incomum se necessitar de mais de uma palavra para esses nomes. Uma forma comum de se contornar essa limitação é nomear variáveis com minúsculas separadas por sublinha (teste_final, por exemplo, comum em C e Python). Outra é escrever em camelúsculas, em que as palavras que compõem o nome são ligadas sem separação, com as iniciais das palavras internas em maiúsculas (testeFinal, por exemplo, usual em Java e Objective C).

    Faça uma função para testar se uma string está no primeiro formato (retorna um bool) – testa se a string é constituída somente de minúsculas e sublinhas. Se quiser fazer um teste mais estrito, teste ainda se cada sublinha tem uma letra de cada lado. Faça outra função para testar o segundo formato – só tem letras, a primeira e a última minúsculas. Faça uma função que recebe uma string contendo um nome no primeiro formato e o converte para o segundo, e outra para fazer o contrário.

    Faça um programa para testar suas funções. Por exemplo, um programa que lê uma string e informa se está em algum dos formatos (ou nenhum), e mostrar como seria convertida para o outro formato.

    Para passar uma letra de minúsculas para maiúsculas, pode usar as funções abaixo:

    bool minuscula(char c)
    {
      return c >= 'a' && c <= 'z';
    }
    
    char minmai(char c)
    {
      if (minuscula(c)) {
        return c - 'a' + 'A';
      } else {
        return c;
      }
    }

    Caso queira dificultar um pouco mais, considere sequências de maiúsculas como imutáveis (num_cabos_HDMI x numCabosHDMI).

Exemplo visto em aula (17set). Conversão de inteiros para string e vice-versa.

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

bool digito(char s)
{
    return s >= '0' && s <= '9';
}


int str_para_int(char s[])
{
    int val = 0;
    int i;
    int sig;
    if (s[0] == '-') {
        sig = -1;
        i = 1;
    } else {
        i = 0;
        sig = 1;
    }

    for (; s[i] != '\0'; i++) {
        if (!digito(s[i])) return val;
        val = val * 10 + (s[i]-'0');
    }

    return sig*val;

}

void inverte_str(char s[]) {
   int t = strlen(s);
   for (int i=0; i<t/2; i++) {
       char x;
       x = s[i];
       s[i] = s[t-i-1];
       s[t-i-1] = x;
   }
}

void int_para_str(int w, char s[])
{
    int i;
    int v;
    if (w < 0) {
        v = -w;
    } else {
        v = w;
    }
    for (i=0; ; i++) {
        s[i] = v%10 + '0';
        v = v/10;
        if (v==0) {
            break;
        }
    }
    if (w < 0) {
        s[i+1] = '-';
        i++;
    }
    s[i+1] = '\0';
    inverte_str(s);
}


int main()
{
    char dig[10];
    char oto[10];
    int val;
    printf("digite uma string com dígitos ");
    scanf("%s", dig);
    val = str_para_int(dig);
    int_para_str(val, oto);
    printf("o valor de %s convertido para int é %d\n", dig, val);
    printf("o valor de %d convertido para str é %s\n", val, oto);
    return 0;
}

Essas conversões podem ser realizadas também pelas versões de printf e scanf que escrevem/lêem de strings em vez de vídeo/teclado:

   sscanf(dig, "%d", &val);
   sprintf(oto, "%d", val);

Mais exercícios com strings (em princípio mais fáceis, faça estes antes se estiver tendo problemas com os anteriores)

Implemente as funções descritas abaixo, e faça programas para testá-las.

  1. void copia_string(char s1[], char s2[]) – copia para o vetor s1 a string contida no vetor s2. Não esqueça de copiar o ‘\0’ do final. Semelhante à função padrão strcpy.
  2. bool copia_str_lim(int tam, char s1[tam], char s2[]) – como a anterior, mas agora sabe-se que o vetor s1 tem espaço para somente tam caracteres, e a função deve obedecer esse limite, copiando no máximo tam-1 caracteres de s2. Retorna false se a string teve de ser truncada. Não esqueça do ‘\0’ no final da string em s1. Semelhante à função padrão strncpy.
  3. int acha_char(char s[], char c) – o vetor s contém uma string delimitada por ‘\0’. Retorna a posição nessa string onde está a primeira ocorrência do caractere c, ou -1 caso não exista tal caractere na string.
  4. bool iguais(char s1[], char s2[]) – retorna true se as strings armazenadas nos vetores s1 e s2 são iguais. Duas strings são iguais se todos os seus caracteres são iguais, e se têm o mesmo número de caracteres.
  5. int pos_dif(char s1[], char s2[]) – os vetores s1 e s2 contêm strings delimitadas com ‘\0’. A função retorna a posição onde está o primeiro caractere que não é igual em s1 e s2. Deve retornar -1 se não existe (as strings são iguais). Exemplos: entre “ai” e “oi” deve retornar 0, entre “abacaxi” e “abacate”, deve retornar 3, entre “a” e “ai” deve retornar 1, entre “oi” e “oi” deve retornar -1.
  6. int acha_substr(char s1[], char s2[]) – retorna a posição na string em s1 onde está a primeira ocorrência da string em s2. Caso não exista (se s2 não for uma substring de s1), retorna -1. Exemplos: entre “cusco caido” e “o c” deve retornar 4, entre “ornatos violeta” e “ole!” deve retornar -1, entre “xuxa e xuxu” e “ux” deve retornar 1.
  7. bool remove_pos(char s[], int pos) – remove, da string armazenada em s1, o caractere que está na posição pos. Retorna true se a remoção foi ok, e false caso contrário (se pos estiver fora dos limites da string). Exemplos: com “cusco” e 2, deve alterar a string para “cuco” e retornar true; com “teste” e 5, deve manter a string inalterada e retornar false.
  8. bool copia_substr(char sub[], char s[], int pos, int tam) – copia para sub a substring de s que começa na posição pos e tem tamanho tam. Não esqueça de terminar a string com ‘\0’. Retorna true se a cópia foi possível. Se pos estiver fora de s, a cópia não é possível, sub é mantida inalterada e o retorno é false. Se a substring for menor que tam (a string em s acaba antes), é feita uma cópia parcial, e retorna true. Exemplos: para s igual a “abacate”, pos igual a 1 e tam igual a 4, s receberá “baca”; No mesmo exemplo, se pos fosse 5, s receberia “te”; se fosse 7, s receberia ""; se fosse 8, s não seria alterado e a função retornaria false.
  9. bool ins_char(char s[], int pos, char c) – insere o caractere c na posição pos da string armazenada em s. Retorna true se a inserção foi ok. A inserção não é possível se pos estiver fora da string. Todos os caracteres em s a partir da posição pos até o final da string devem ser movidos uma posição para diante. Essa cópia tem que ser feita de trás para diante na string. Não esqueça de copiar o ‘\0’.
  10. bool ins_char_lim(int n, char s[n], int pos, char c) – como a anterior, mas agora se sabe a capacidade do vetor s. Se a string resultante for grande demais para caber em s, deverá ser truncada. Exemplos: 6, “bah”, 1, ‘l’ - altera a string para “blah”; 4, “bah”, 1, ‘l’ - altera a string para “bla”.
  11. bool rem_substr(char s[], int pos, int tam) - remove da string em s a substring que inicia am pos e tem tamanho tam. Retorna false se pos está fora da string s (e não altera s nesse caso). Se tam for além do final de s, remove até o fim de s (e retorna true). Exemplos: “abacaxi”, 2, 4 -> “aba”; “blah”, 0, 20 -> "“;”xis“, 3, 3 ->”xis“(e retorna true);”xis“, 4, 3 ->”xis" (e retorna false).
  12. bool ins_substr(char s[], int pos, char sub[]) – insere na string em s, na posição pos, a substring em s2. Retorna false se pos estiver fora de s. Exemplo: “abi”, 2, “acax” -> “abacaxi”.
  13. bool rem_substr_lim(int n, char s[n], int pos, char sub[]) – como a anterior, mas conhecendo o tamanho do vetor s. A string resultante deve ser truncada para caber em s, se for necessário.
  14. bool substitui_substr_lim(int n, char s[n], int pos, int tam, char sub[])s é um vetor com capacidade para n chars, que contém uma string delimitada por ‘\0’; sub contém outra string. A função deve substituir a substring de s que inicia na posição pos e tem tem caracteres pela string em sub. Caso a string resultante não caiba em s, deve ser truncada. Retorna false se a substituição não é possível (pos está fora de s). Exemplo: 5, “caneta”, 1, 1, “oi” -> “coin”. (Fazendo essa, tá formado em string!)

Referências (ponteiros)

Uma referência é um dado que permite o acesso indireto a um outro dado. Tendo uma referência para uma variável é possível obter-se o valor dessa variável ou alterar esse valor, mesmo sem ter acesso direto a essa variável. Um dos principais usos de referências é permitir que uma função possa alterar uma variável de uma outra função, de forma controlada, porque a função detentora da variável passa explicitamente a referência a sua variável. Já usamos referências em duas situações: quando uma função passa para outra um vetor como argumento, está na verdade passando uma referência a esse vetor, de forma que a função chamada tem acesso ao vetor da função chamadora, e pode realizar alterações sobre ele. Um outro uso de referências que fizemos é nas chamadas a scanf, para permitir que a função scanf possa alterar as variáveis onde se espera que ela coloque os valores convertidos da entrada. Por isso a necessidade de se usar o caractere & antes do nome de uma variável que vai ser alterada pelo scanf. O & é um operador que opera sobre uma variável, produzindo uma referência a ela.

Para ser usada, uma referência deve ser colocada em uma variável. Uma variável que pode receber uma referência a outra é chamada de ponteiro. Por exemplo, se p for um ponteiro e v for uma outra variável, pode-se alterar esse ponteiro para que ele referencie a variável v com o comando

   p = &v;

Quando um ponteiro tem uma referência para uma determinada variável, por vezes diz-se que o ponteiro “aponta” para a variável. Tendo o ponteiro p apontando para a variável v, pode-se acessar a variável apontada através do operador de “dereferenciação”, representado em C pelo caractere * (é, o mesmo usado para representar a multiplicação). Então, para se colocar em w o valor da variável v qu é apontada pelo ponteiro p, usa-se o comando:

   w = *p;

Para se alterar o valor da variável v apontada pelo ponteiro p, também usa-se o operador de dereferenciação:

   *p = 42;

Uma variável do tipo ponteiro aponta para uma outra variável, que pode ter qualqeur tipo da linguagem (exceto vetor). Um ponteiro é “tipado”, ele aponta para valores de um determinado tipo. Para cada tipo da linguagem, existe um tipo de ponteiro correspondente. Quando se declara uma variável como sendo um ponteiro, deve-se também fornecer a informação de qual o tipo de dado para o qual esse ponteiro aponta. Para se declarar p como um ponteiro que aponta para valores do tipo int, usa-se:

   int *p;

Ou seja, o tipo, seguido de asterisco (de novo!), seguido do nome da variável ponteiro.

Como foi dito, um dos principais usos de ponteiros é para permitir que uma função altere variáveis de outra. Para exemplificar, digamos que queremos uma funçao que calcula a raiz de uma equação de segundo grau, mas queremos também saber se o cálculo deu certo (se a função não tem raiz ou se tem mais de uma o cálculo naão deu certo). Temos nesse caso duas informações a retornar, a raiz e a informação de ter sido bom sicedido ou não. Podemos usar um ponteiro para retornar a raíz e usar o retorno normal da função para informar se tudo deu certo. A função poderia ser algo assim:

   bool raiz2g(float a, float b, float c, float *r)
   {
     float delta = b*b - 4*a*c;
     if (delta == 0) {
       *r = -b/2/a;
       return true;
     } else {
       return false;
     }
   }

A chamada para essa função poderia ser algo como:

  float raiz;
  if (raiz2g(10, 3, 4, &raiz)) {
    printf("a única raiz é %f\n", raiz);
  } else {
    printf("a equação não tem uma só raiz!\n");
  }

Exercícios

  1. Faça uma função que recebe dois números float e retorna (através de ponteiros), o quociente inteiro e o resto da divisão desses dois números. O quociente inteiro de a/b é o maior inteiro q que satisfaz q*b <= a. O resto r é o que sobra: r = a - q*b. Considere que a e = b são positivos. Faça um programa para testar sua função. O resto é float.

Ponteiros e vetores

O nome de um vetor representa uma referência a esse vetor. Por isso que quando passamos um vetor para uma função, essa função pode acessar e alterar o vetor recebido, diferente de dados de outros tipos. A referência representada pelo nome do vetor na verdade é uma referência ao primeiro elemento desse vetor. A partir dessa referência a função consegue acesso aos demais elementos do vetor, porque eles são colocados contiguamente em memória, em posições consecutivas. Então, se v é um vetor, v e &v[0] são sinônimos. Quando uma função declara que recebe um vetor como parâmetro, pode ser passado para ela tanto um como outro desses sinônimos. Por exemplo, as duas chamadas à função f abaixo são equivalentes:

   int f(int x[]) { ... }

   // ...

   int v[10];
   f(v);
   f(&v[0]);

Essa equivalência tem consequências grandes na linguagem. Se pode-se passar um ponteiro para o elemento 0 de um vetor, prque não seria possível passar um ponteiro para outro elemento do vetor? Pode. Nesse caso, a função trata como um vetor completo um pedaço do vetor, a partir da posição passada. No código abaixo, a função f está recebendo, em seu parâmetro x, uma referência ao elemento 5 do vetor v da função g. Se ela acessar x[0], estará acessando v[5]. O código está alterando x[2], que corresponde ao elemento v[7].

   void f(int x[])
   {
      x[2] = 42;
   }

   void g(void)
   {
      int v[10];
      f(&v[5]);
   }

Se a função que tem como argumento um vetor está recebendo na verdade uma referência a um elemento desse vetor, então esse argumento poderia ser declarado como um ponteiro. A linguagem vai tão longe nessa equivalência que permite não só que o argumento seja declarado como ponteiro como permite que se continue usando a mesma notação de acesso a vetor. A função f abaixo é equivalente à mostrada acima.

   void f(int *x)
   {
      x[2] = 42;
   }

Do outro lado, como o nome de um vetor na verdade representa uma referência para seu primeiro elemento, podemos passar um vetor para uma função que espera um ponteiro:

   void g(void)
   {
       int v[10];
       f(v);  // chama a função acima, em que x é declarado como int *x
   }

Por causa dessas equivalências, ponteiros são usualmente usados em C para acessar vetores. Essa mistura é ainda facilitada pela noção de aritmética de ponteiros, presente na linguagem.

Aritmética de ponteiros

Um ponteiro é um tipo de dados cujo valor representa uma referência a uma outra variável. Essa referência corresponde à posição na memória onde o valor dessa outra variável está armazenado. Essa posição é um número, também chamado de endereço de memória. Os valores dos ponteiros podem ser usados em operações aritméticas, mas têm um significado um pouco diferente das operações aritméticas em valores numéricos normais.

A aritmética de ponteiros é destinada a facilitar o manuseio de dados armazenados em posições consecutivas de memória, como é o caso de vetores. Quando se tem um ponteiro que referencia um elemento de um vetor, se se soma o valor 1 a esse ponteiro, obtém-se um ponteiro que referencia o próximo elemetno desse mesmo vetor. No código abaixo, se está alterando o valor de v[2]:

   int v[10];
   int *p;
   p = &v[1];
   p = p+1;
   *p = 30;

Pode-se somar (ou subtrair) qualquer valor inteiro a um ponteiro (claro, deve-se garantir que o ponteiro continua apontando para algum elemento do vetor). O código abaixo altera v[0]:

   int v[10];
   int *p;
   p = &v[4];
   p = p-4;
   *p = 10;

Como o resultado de uma soma de um ponteiro com um inteiro é um ponteiro, pode-se usar esse resultado diretamente, sem precisar armazenar em uma variável. O c;ódigo abaixo também altera o valor de v[0]:

   int v[10];
   int *p;
   p = &v[4];
   *(p-4) = 10;

São necessários os parênteses, porque o operador de dereferenciação tem maior precedência que o de soma – *p-4 quer dizer “quatro a menos que o valor apontado por p”; *(p-4) quer dizer “o valor apontado por um ponteiro que fica 4 posições antes de p”.

Além de soma e subtração entre um ponteiro e um inteiro, existe também a operação de subtração entre dois ponteiros que apontem para elementos de um mesmo vetor. O resultado é o número de elementos que existem entre esses dois ponteiros. Dá para entender analisando o que acontece com o código abaixo:

   int v[10];
   int *p;
   int *q;
   int d;
   p = &v[5];
   q = p+2;  // q aponta para v[7]
   d = q-p;  // d recebe 2, o número que foi somado a p para obter q

Tem-se com isso duas notações para acesso a elementos de um vetor usando ponteiros, a notação de vetor e a notação de aritmética de ponteiros. Os dois últimos comandos do código abaixo são equivalentes:

   int v[10];
   int *p;
   p = &v[5];
   p[2] = 30;   // coloca 30 em v[7]
   *(p+2) = 30; // coloca 30 em v[7]

A notação de acesso a vetores usando ponteiros é especialmente comum em C para o tratamento de strings. Funções que tratam com strings tipicamente são declaradas com char *s e não com char s[], como vínhamos usando. E tipicamente usa-se a notação de aritmética de ponteiros e não a notação de vetor para manipulação de strings. Por exemplo, a copia de strings poderia ser implementada assim:

   void copia_str(char *s1, char *s2)
   {
     while (*s2 != '\0') {
       *s1 = *s2;
       s1 = s1+1;
       s2 = s2+1;
     }
     *s1 = '\0';
   }

Usando o operador de incremento ++, pode-se reescrever assim:

   void copia_str(char *s1, char *s2)
   {
     while (*s2 != '\0') {
       *s1 = *s2;
       s1++;
       s2++;
     }
     *s1 = '\0';
   }

O operador de pós-incremento ++ tem precedência sobre o operador de dereferenciação *, esse código pode ficar mais conciso:

   void copia_str(char *s1, char *s2)
   {
     while (*s2 != '\0') {
       *s1++ = *s2++;
     }
     *s1 = '\0';
   }

Exercícios

  1. Refaça os exercícios de string usando a notação de ponteiro em vez de vetor.

Operadores de atribuição

A linguagem C tem vários operadores que alteram o valor de variáveis. O principal deles é o operador de atribuição =, que atribui à variável à esquerda dele o valor da expressão à direita. Ele foi chamado de comando anteriormente, mas na verdade ele é um operador, que recebe dois operandos (um à direita e um à esquerda) e produz um resultado. Além disso, ele tem um “efeito colateral”, que é alterar o valor da variável que está à esquerda. Em geral, ele é usado por causa desse efeito colateral, e o resultado que ele produz é ignorado. O resultado é o mesmo valor que é atribuído à variável, e esse resultado opde ser usado em expressões. Por exemplo, a expressão a + (x = b + 2) tem o valor de a+b+2 e tem oefeito colateral de alterar o valor de x para b+2. Os parênteses são necessários porque à esquerda do operador = tem que ter um nome de variável, e esse operador tem precedência mais baixa que o operador de soma +. Sem os parênteses a expressão acima seria interpretada como (a + x) = (b + 2), o que seria um erro, porque não se pode atribuir um valor a uma expressão (a+x). Além de ter precedência baixa, o operador de atribuição tem associatividade à direita, o que quer dizer que se houverem dois desses operadores, o da direita vai ser executado antes do da esquerda. Isso é usado para se realizar diversas atribuições iguais em um mesmo comando (a = b = c = 25; atribui o valor 25 às variáveis a, b e c).

Além desse, a linguagem tem os operadores de incremento e decremento (++ e --), em suas versões pós e pré. Esses operadores têm um único operando (são operadores unários), e esse operando pode estar à direita ou à esquerda do operador (o tal pós ou pré).

Esses operadores operam sobre o seu operando e produzem um resultado. Além disso, tem o efeito colateral de alterar o valor do seu operando (a alteração é incremenar ou decrementar de um). Por causa desse efeito colateral, o operando obrigatoriamente é uma variável, não pode ser uma expressão quelquer. O resultado produzido pelo operador depende de ele ser usado antes (pré-incremento, por exemplo) ou depois (pós-incremento) do operando. Se ele for usado depois do operando, o resultado é o valor do operando antes do incremento; se antes, o resultado é o valor após o incremento. Exemplos:

  a = 5;
  b = a++;  // b vale 5, a vale 6
  a = 5;
  b = ++a;  // b vale 6, a vale 6
  a = 5;
  b = --a;  // b vale 4, a vale 4
  a = a++;  // resultado indefinido, não use.

Não se deve usar mais de uma atribuição a uma mesma variável em um mesmo comando, a linguagem não define a ordem em que as atribuições são realizadas, e diz que o resultado é indefinido (o que quer dizer que seja lá qual valor for produzido, a culpa não é do compilador nem dos criadores da linguagem, mas do programador).

Os demais operadores de atribuição da linguagem são operadores de acumulação. Eles têm uma variável à sua esquerda e uma expressão à direita. O valor da expressão é acumulado à variável, de acordo com a operação desejada. Grande parte dos operadores binários da linguagem têm um operador de acumulação associado, construído adicionando um = à direita do símbolo do operador. Por exemplo:

   x += 5;   // equivale a  x = x + 5;
   x *= a+b; // equivale a  x = x * (a+b);
   x /= 2;   // equivale a  x = x / 2;

Para não dificultar demais a legibilidade de um programa, deve-se limitar o uso desses operadores aos casos mais simples. Apesar de permitido pela linguagem, um comando como o abaixo serve bem mais para confundir do que qualquer outra coisa:

  d+=c+++a++-++b;

Se as quatro variáveis tinham valor 0 antes desse comando, quanto valerão depois? Quanto tempo levaste para achar a resposta? Tem certeza que está correto?

Alocação dinâmica de memória

A forma principal de abstração para a memória da linguagem C são as variáveis. A criação de uma variável é uma forma organizada de se dizer ao compilador que se quer um tanto de memória, que esse tanto vai ser usado para armazenar dados de um determinado tipo, que vai passar a ser referenciado por tal nome dentro do programa. O compilador vai então verificar que todo o uso dessa memória é realizado de acordo com esse “contrato”, e vai tentar otimizar a quantidade de memória necessária para esse uso (por exemplo, quando uma função começa sua execução, suas variáveis precisam de memória, mas quando uma função termina de executar, essas variáveis não são mais necessárias, e a memória que elas tão utilizando pode ser reutilizada para outra coisa – isso é feito automaticamente pelo compilador).

Mas essa forma de usar memória por vezes tem limitações, e em algumas situações surge a necessidade de se ter um controle maior sobre o uso da memória. Por exemplo, para poder usar memória além da pré-definida pelas variáveis presentes no programa (pense em um programa que só vai poder definir quanto de memória vai precisar depois que já está executando, porque lê dados de um arquivo, por exemplo), ou para organizar o uso da memória de uma forma diferente da imposta pela alocação e liberação ligada automaticamente à ordem de execução das funções (uma função que cria uma variável e gostaria que ela pudesse ser usada pela função que a chamou, por exemplo).

Para esses casos, tem-se a alocação explícita de memória (mais conhecida como alocação dinâmica, que é um nome pior, porque a alocação automática feita pelo mecanismo de execução das funções também é dinâmica). Nessa forma de alocação de memória, é o programador quem realiza a alocação e a liberação da memória, no momento que considerar mais adequado. Essa memória é por vezes chamada de anônima, porque não é vinculada a uma variável com nome pré-definido.

Como essa memória não é associada a variáveis, a forma que se tem para usar esse tipo de memória é através de ponteiros.

Existem duas operações principais de manipulação desse tipo de memória: a operação de alocação e a operação de liberação de memória. Quando se aloca memória, se diz quanto de memória se quer (quantos bytes), e se recebe do sistema esse tanto de memória, na forma de um ponteiro para a primeira posição do bloco de memória alocado. As demais posições seguem essa primeira, de forma contígua, como em um vetor. Para se liberar a memória, passa-se um ponteiro para essa mesma posição, o sistema sabe quanto de memória foi alocada e faz o necessário para disponibilizar essa memória para outros usos. Depois de liberado, o bloco de memória não pode mais ser utilizado.

Essas operações estão disponibilizadas em C na forma de funções, acessíveis incluindo-se stdlib.h. Essas funções são malloc e free. A função malloc recebe um único argumento, que é a quantidade de bytes que se deseja, e retorna um ponteiro para a região alocada, que tem esse número de bytes disponíveis. Caso a alocação não seja possível, o ponteiro retornado tem um valor especial, chamado NULL. Sempre deve-se testar o valor retornado por malloc para verificar se a alocação de memória foi bem sucedida.

A função free recebe um único argumento, que é um ponteiro para a primeira posição de memória do bloco a ser liberado, obrigatoriamente o mesmo valor retornado por um pedido de alocação de memória anterior.

Não existe limitação no tamanho de um bloco a alocar, nem na quantidade de blocos alocados, a não ser a quantidade de memória disponível.

Para facilitar o cálculo da quantidade de memória, existe o operador sizeof, que dá o número de bytes usado por qualquer tipo de dados. Por exemplo, sizeof(double) diz quantos bytes de memória são necessário para se armazenar um valor do tipo double.

Como a memória alocada é contígua, uma forma usual de se usar a memória alocada é como um vetor. Como vimos anteriormente, o uso de um vetor através de um ponteiro é muito semelhante (pra não dizer igual) ao uso de um vetor diretamente. O fato de o ponteiro estar apontando para memória alocada explicitamente ou estar apontando para memória que pertence a um vetor de verdade não muda a forma de uso.

Por exemplo, para se alocar memória para se usar como um vetor de tamanho definido pelos dados, pode-se usar algo como:

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

float calcula(int n, float v[n])
{
  // faz um cálculo complicado sobre os elementos do vetor
  float t=0;
  for (int i=0; i<n; i++) {
    t += v[i];
  }
  return t/n;
}

int main()
{
  float *vet;
  int n;
  printf("Quantos dados? ");
  scanf("%d" , &n);

  vet = malloc(n * sizeof(float));
  if (vet == NULL) {
    printf("Me recuso a ser explorado dessa forma vil!\n");
    return 5;
  }

  // a partir daqui, vet pode ser usado como se fosse um vetor de tamanho n
  for (int i=0; i<n; i++) {
    printf("digite o dado %d ", i);
    scanf("%f", &vet[i]);
  }
  float resultado = calcula(n, vet);
  printf("O resultado do cálculo é: %f\n", resultado);
  free(vet);
  // a partir daqui, a região apontada por vet não pode mais ser usada.
  return 0;
}

Exercícios

Quando se precisa armazenar grandes números em um programa, usa-se os tipos de dados inteiros maiores. Infelizmente, o maior dos tipos inteiros da linguagem C, long long, tipicamente consegue representar somente números até alguns poucos quintilhões. E se for necessário trabalhar com números maiores? Nesse caso, recorre-se a regiões maiores de memória, e se desenvolve funções para manipular os números armazenados nessas regiões. Esta sequência de exercícios é para você implementar um grupo de funções para trabalhar com grandes números, e para treinar alocação dinâmica de memória. Armazenaremos cada número em um vetor de caracteres, alocado dinamicamente, com tamanho adequado ao número que será armazenado. Vamos usar representação decimal, com um byte para cada dígito. Usaremos mais um byte para o sinal. Usaremos mais um byte para armazenar o número de dígitos do nosso número (e consequentemente o tamanho da memória alocada. A memória alocada será sempre dois a mais que o número de dígitos do número, e será usada da seguinte forma:

Por exemplo, o número 142 será armazenado em uma região de memória contendo 5 bytes: 3, '+', '2', '4', '1'.

  1. Implemente uma função que recebe uma string contendo um número representado em decimal e retorna um ponteiro para uma região de memória alocada e contendo esse número usando a notação descrita acima. A função deve não alocar nada e retornar o valor NULL caso a string não contenha um número válido (caracteres inválidos ou mais de 100 dígitos). Por exemplo, se receber a string “-1024”, deve alocar uma região de memória com 6 bytes, inicializá-los com os valores 4, '-', '4', '2', '0', '1' e retornar o ponteiro para essa região.
  2. Implemente uma função que recebe um ponteiro para um número como descrito acima (vamos chamar de “numerão”) e um vetor de char, e coloca nesse vetor uma string com a representação decimal normal desse número (para poder ser escrito, por exemplo). O vetor tem capacidade suficiente.
  3. Implemente uma função que remove zeros no início de um número. Ela recebe um ponteiro para uma região de memória que foi alocada dinamicamente e que contém um número no nosso formato, aloca uma nova região com o tamanho adequado para armazenar esse mesmo número sem os zeros iniciais, copia o número para essa nova região sem os zeros, libera a memória do número original e retorna um ponteiro para a nova região. A função pode ser otimizada para não alocar nem liberar memória e simplesmente retornar o ponteiro recebido, caso não tenha dígitos a remover. Por exemplo, se a função recebe uma região contendo 5, '+', '3', '4', '0', '0', '0', deve devolver uma região com 2, '+', '3', '4'.
  4. Implemente uma função que recebe um numerão e o número de dígitos que se quer que ele tenha, e retorna um novo numerão, que contém o mesmo valor e pelo menos o número de dígitos pedido (o número recebido deve ser completado com zeros). A função deve alocar e liberar memória conforme a necessidade. Se o número de dígitos pedido for menor que o necessário para armazenar o numerão, retorna-o como menor número de dígitos possível. Por exemplo, se a função receber 2 '-' '5' '3' e 4, deve retornar com 4 '-' '5' '3' '0' '0'; se receber 4 '+' '7' '8' '0' '0' e 3, deve retornar 3 '+' '7' '8' '0'. A função deve aceitar que o resultado tenha até 101 dígitos (útil para a soma).
  5. Implemente uma função que copia um numerão. Ela recebe um numerão, aloca memória, coloca uma cópia do numerão recebido nessa nova memória e retorna um ponteiro para esse novo numerão. O numerão recebido não deve ser alterado.
  6. Implemente uma função auxiliar de soma de dois numerões. Essa função é auxiliar porque soma os dígitos ignorando os sinais. Ela recebe dois numerões e retorna um terceiro, recém alocado, contendo a soma. Os dois numerões recebidos não devem ser alterados. Dica: copie os dois valores recebidos, remova os zeros excedentes, iguale o número de dígitos para um a mais que o maior deles (para ter certeza que não terá um “vai um” além do final dos números), faça a soma dígito a dígito sabendo que tem dígitos suficientes (não tem caso especial). Não esqueça de apagar as cópias.
  7. Implemente uma função auxiliar de subtração de dois numerões. Ela é auxiliar porque ignora os sinais e porque pode confiar que o segundo número não é maior que o primeiro (não vai ter problema com empréstimo no último dígito).
  8. Implemente a função de soma de dois numerões. Ela deve receber os dois números, comparar os valores (faça uma função auxiliar de comparação) e sinais, chamar a função auxiliar de soma ou subtração, ajeitar o resultado. O resultado não deve conter zeros extras.
  9. Implemente uma função de subtração de dois numerões. Ela deve fazer as mesmas preparações que a soma. Na verdade, ela pode usar a função de soma entre o primeiro número e o negativo do segundo.
  10. Faça um programa para testar. Ele deve ler números do usuário (como strings). Depois de ler o primeiro número, lê a operação desejada (soma, subtração ou fim) e um outro número, faz a operação, imprime o resultado, volta a pedir outra operação etc.

Na implementação das funções de soma e subtração, os dados das parcelas não devem ser alterados. Se for necessário alocar memória temporária, ela deve ser liberada.

Solução possível de alguns exercícios

// solucao do 1
char *numerao_de_str(char *str)
{
  char *numerao = NULL;
  // verifica se a str esta ok -- so tem digitos (e talvez um sinal no inicio)
  if (str_tem_numero(str)) {
    // descobre o sinal e a quantidade de digitos do numero
    char sinal = '+';
    int numdig = strlen(str);
    if (str[0] == '-' || str[0] == '+') {
      sinal = str[0];
      numdig--;
    }
    // aloca memória para o numerão (2 a mais que os dígitos)
    numerao = malloc(numdig+2);
    // verifica se a alocacao foi OK. Se nao, irá retornar NULL
    if (numerao != NULL) {
      // inicializa a parte fixa do numerão
      numerao[0] = numdig;
      numerao[1] = sinal;
      // copia os dígitos de trás pra diante (sem copiar o sinal)
      int pos = 2;
      for (int i=strlen(str)-1; i>=0; i--) {
        char dig = str[i];
        if (dig < '0' || dig > '9') break;
        numerao[pos++] = dig;
      }
      // se a string começa com zeros, remove eles
      numerao = numerao_remove_zeros(numerao);
    }
  }
  return numerao;
}
// solucao do 10
int main() {
  char *acumulador;
  char str[102];

  printf("Somador de grandes números\n");
  printf("Digite um número (limitado a 100 dígitos): ");
  scanf("%s", str);
  acumulador = numerao_de_str(str);
  if (acumulador == NULL) {
    printf("não gostei desse número\n");
    return 1;
  }
  for (;;) {
    char op;
    do {
      printf("qual operação (+, - ou f)? ");
      scanf(" %c", &op);
    } while (op != '+' && op != '-' && op != 'f');
    if (op = 'f') break;
    printf("Digite um número: ");
    scanf("%s", str);
    char *aux;
    aux = numerao_de_str(str);
    if (aux == NULL) {
      printf("não gostei desse número\n");
      break;
    }
    char *tmp;
    if (op == '+') {
      tmp = numerao_soma(acumulador, aux);
    } else {
      tmp = numerao_subtrai(acumulador, aux);
    }
    if (tmp == NULL) {
      printf("problema interno\n");
      free(aux);
      break;
    }
    free(acumulador);
    free(aux);
    acumulador = tmp;
    str_de_numerao(acumulador, str);
    printf("Resultado: %s\n", str);
  }
  printf("Ciao\n");
  free(acumulador);
  return 0;
}

Curiosidade

Aparentemente tem um pessoal na microsoft que ainda não descobriu como fazer alocação dinâmica de memória: falha no excel.

Arquivos

Nos programas que desenvolvemos até agora, os dados que foram manipulados estavam na memória principal, acessados na forma de variáveis ou de regiões de memória alocadas explicitamente. Essa memória é de uso temporário, ela é toda liberada para reuso quando o programa termina sua execução. Existem situações, porém, em que os dados devem sobreviver os programas. Na verdade, esse é o caso mais comum, e os dados tipicamente têm mais valor que os programas que os manipulam.

Para garantir a sobrevida dos dados, eles são armazenados em unidades de memória externa aos programas, em dispositivos de armazenamento, na forma de arquivos. Um arquivo é uma região de um dispositivo de armazenamento, que é identificado por um nome, e contém uma quantidade qualquer de bytes. Para que o conteúdo de um arquivo possa ser manipulado por um programa, ele deve ser copiado para a memória principal do computador, em uma operação de leitura. Para que dados da memória principal sejam colocados em arquivos, eles devem ser transferidos para esses arquivos, em uma operação de escrita.

Para se poder realizar uma operação de leitura ou de escrita sobre um arquivo, ele inicialmente deve ser “aberto” – o programa deve indicar o nome do arquivo que quer acessar e o tipo de operação que pretende realizar. Após a realização das operações de leitura ou escrita sobre o arquivo, ele deve ser fechado, para que o sistema atualize corretamente as atualizações sobre o dispositivo de armazenamento.

Resumindo, são 4 as operações básicas sobre arquivos: abertura, leitura, escrita e fechamento. As operações de leitura, escrita e fechamento só podem ser realizadas sobre um arquivo que tenha sido previamente aberto.

Em C, o acesso a arquivos é feito através de funções. Vaoms ver um conjunto simples de 5 funções para o manuseio de arquivos, correspondentes às 4 operações discutidas anteriormente mais uma para verificar se passamos do final do arquivo. Para ter acesso a essas funções, o programa deve incluir stdio.h. Em um programa, um arquivo é representado por uma variável, do tipo FILE *. Essa variável deve ser inicializada na abertura do arquivo e deve ser fornecida para as demais funções de manipulação do arquivo, para identificar sobre qual arquivo se quer realizar tal operação. As 4 funções são:

Exemplo de um programa que lê números de um arquivo e informa a soma dos números lidos:

#include <stdio.h>

int main()
{
  FILE *arq;

  arq = fopen("dados", "r");
  if (arq == NULL) {
    printf("Não foi possível abrir o arquivo 'dados' para leitura\n");
    return 1;
  }

  long soma = 0;
  int nnum = 0;
  for (;;) {
    int val;
    if (fscanf(arq, "%d", &val) != 1) {
      break;
    }
    soma += val;
    nnum++;
  }
  fclose(arq);

  printf("Foram lidos %d números do arquivo, que somam %ld\n", nnum, soma);
  return 0;
}

Outro exemplo, um programa que cria um arquivo com dados interessantes para o programa anterior:

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

int main()
{
  FILE *arq;
  
  arq = fopen("dados", "w");
  if (arq == NULL) {
    printf("Não foi possível abrir o arquivo 'dados' para escrita.\n");
    return 1;
  }

  for (int i=0; i<100; i++) {
    fprintf(arq, "%d\n", rand());
  }

  fclose(arq);
  return 0;
}

Outro exemplo, um programa que copia um arquivo para outro, retirando espaços duplicados:

#include <stdio.h>
#include <stdbool.h>

int main() {
  char nome_entrada[30], nome_saida[30];
  FILE *entrada, *saida;

  printf("Programa de copia de arquivos.\n");
  printf("Digite o nome do arquivo de entrada: ");
  scanf(" %s", nome_entrada);
  entrada = fopen(nome_entrada, "r");
  if (entrada == NULL) {
    printf("Não foi possível abrir o arquivo '%s'.\n", nome_entrada);
    return 1;
  }

  printf("Digite o nome do arquivo de saída: ");
  scanf(" %s", nome_saida);
  saida = fopen(nome_saida, "w");
  if (saida == NULL) {
    printf("Nao foi possível abrir o arquivo '%s'.\n", nome_saida);
    return 2;
  }

  int bytes_lidos = 0, bytes_escritos = 0;
  bool ultimo_foi_espaco = false;
  for (;;) {
    char c;
    fscanf(entrada, "%c", &c);
    if (feof(entrada)) {
      break;
    }
    bytes_lidos++;
    if (c != ' ' || !ultimo_foi_espaco) {
      fprintf(saida, "%c", c);
      bytes_escritos++;
    }
    if (c == ' ') {
      ultimo_foi_espaco = true;
    } else {
      ultimo_foi_espaco = false;
    }
    // esse if poderia ser trocado por   ultimo_foi_espaco = (c == ' ');
  }

  fclose(entrada);
  fclose(saida);
  printf("Foram lidos %d bytes e gravados %d.\n", bytes_lidos, bytes_gravados);
  return 0;
}

Exercícios

  1. Altere o programa exemplo para ler os números como string, e somá-los como numerao. Crie um arquivos de dados com números grandes o suficiente que justifique o uso de numerao.
  2. Faça um programa que conta quantos números tem em um arquivo, fecha o arquivo, abre novamente, lê todos esses números para um vetor (agora ela já sabe o tamanho do vetor), ordena os número nesse vetor e grava um novo arquivo com os mesmos números, ordenados.

Registros

Nos tipos de variáveis que vimos até agora, ou armazenamos um único valor em uma variável, ou vários valores do mesmo tipo, em um vetor. Nesse último caso, cada um dos vários valores é identificado por posição. Em algumas situações, gostaríamos de agrupar vários valores em uma mesma variável, mas identificá-los por posição pode não ser muito natural. Por exemplo, se quisermos uma variável que identifique uma data, que contém 3 valores (dia, mês e ano). Colocar esses 3 valores em um vetor não vai levar a um programa especialmente fácil de ser entendido. Outras vezes, nem todos os dados que se quer reunir são do mesmo tipo – imagine por exemplo que se queira agrupar vários dados a respeito de um produto (descrição, preço, data de aquisição, etc).

Para esses casos, tem-se uma outra forma de se agrupar dados em uma só variável, o registro ou estrutura, como é chamado em C (struct). Em um registro, pode-se colocar quantos dados quiser, cada um deles podendo ser de qualquer dos tipos da linguagem (inclusive vetores e registros). A forma de se identificar cada um desses dados é através de um nome. Cada um dos dados componentes de um registro é chamado de campo.

Por exemplo, uma variável chamada nascimento poderia armazenar uma data, em um registro contendo três campos, dia, mes, ano, todos do tipo int. Essa variável poderia ser declarada assim:

  struct {
    int dia;
    int mes;
    int ano;
  } nascimento;

A forma de se acessar um campo de uma variável do tipo registro é separando o nome da variável do nome do registro por um ponto (.). Por exemplo, para colocar o valor 30 no campo dia da variável declarada acima, e depois imprimir a data, pode-se usar o código abaixo:

  nascimento.dia = 30;
  printf("Fulano nasceu em %02d/%02d/%04d.\n,
         nascimento.dia, nascimento.mes, nascimento.ano);

O tipo da variável nascimento declarada acima é struct { int dia; int mes; int ano; }. Para evitar ter que escrever isso tudo ao se declarar outras variáveis do mesmo tipo, ou ideclarar funções que recebam ou retornem dados desse tipo, geralmente se cria “apelidos” ou nomes alternativos para esses tipos. Existem duas formas de se dar nomea a estruturas em C. Uma delas é com “etiquetas” (tag em inglês). Coloca-se, entre struct e o { um nome para o registro. A partir daí, pode-se usar struct nome sempre que precisar se referir a esse tipo. Por exemplo, a definição do tipo de registro de nascimento poderia receber o nome data, e ser usada para declarar uma outra variável, chamada hoje, que também é uma dessas estruturas:

  struct data {
    int dia;
    int mes;
    int ano;
  } nascimento;

  struct data hoje;

A segunda forma é se dando um nome ao tipo. Isso pode ser feito com qualquer tipo em C, através da palavra chave typedef. Ela funciona como a declaração de uma variável, mas, em vez de criar uma variável, cria um novo tipo. Esse novo tipo pode ser usado em qualquer lugar que o tipo struct poderia ser usado. O exemplo acima ficaria assim:

  typedef struct {
    int dia;
    int mes;
    int ano;
  } data;    // cria o tipo "data"

  data nascimento;
  data hoje;

De qualquer das formas, um registro pode ser passado e retornado de uma função. Como nos tipos básicos da linguagem, a passagem de registros é feita por cópia e não por referência, como os vetores. Uma função que recebe uma data e calcula quantos dias se passaram desde o início do ano poderia ser:

int dias_desde_ano_novo(data dia)
{
                  // J31 F28 M31 A30  M31  J30  J31  A31  S30  O31  N30  D31
  int dias_antes[] = { 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 303, 334 };
  int n_dias;
  // calcula quantos dias se passaram até o início do mês
  n_dias = dias_antes[dia.mes-1];
  if (bissexto(dia.ano) && dia.mes > 2) n_dias++;
  // soma o dia da data
  n_dias += dia.dia;
  return n_dias;
}

Uma função que faz o contrário, retorna a data que corresponde a tantos dias depois do início de um ano:

data data_desde_ano_novo(int ano, int dias)
{
  // se tem mais dias do que cabe no ano, avança para o ano seguinte
  while (dias > dias_no_ano(ano)) {
    dias -= dias_no_ano(ano);
    ano++;
  }
  // chuta a data para o dia primeiro do ano
  data retorno = { 1, 1, ano };
  // se o número de dias é maior que o mês, avança para o mês seguinte
  while (dias > dias_no_mes(retorno.mes, retorno.ano)) {
    dias -= dias_no_mes(retorno.mes, retorno.ano);
    retorno.mes++;
  }
  retorno.dia = dias;
  return retorno;
}

Como mostrado no exemplo acima, uma variável do tipo estrutura pode ser inicializada com os valores dos campos separados por vírgula, entre chaves. Os valores devem estar na ordem em que aparecem na definição da estrutura. Pode-se atribuir uma estrutura a outra, mas não tem atribuição de valor constante:

   data d1 = {10, 10, 2010};
   data d2;

   d2 = d1; // OK
   d2 = {20, 10, 2020}; // INVÁLIDO -- provavelmente em uma proxima versao possa

Ponteiros para estruturas

Assim como qualquer outro tipo de dados da linguagem, uma estrutura também pode ser apontada por um ponteiro.

  data d1;
  data *p;
  p = &d1;

Para se acessar os campos de uma estrutura apontada por um ponteiro, temos um probleminha. No código acima, para acessar o campo dia da varável d1, usamos d1.dia. Para acessar d1 através do ponteiro, usamos *p. Então, para acessar o campo dia da variável d1 através do ponteiro p, deveria ser *p.dia. Infelizmente não é, porque temos dois operadores, o operador * e o operador ., e o operador . tem precedência sobre o operador *. A expressão *p.dia significa *(p.dia) e nós precisamos de (*p).dia. A notação *(p.dia) significa: p é uma estrutura, que contém um campo chamado dia; esse campo é um ponteiro, e eu quero rereferenciar esse ponteiro. É um significado perfeitamente válido, e bastante usado. O outro significado, que é o que queremos (p é um ponteiro para uma estrutura que tem um campo chamado dia e eu quero acessar esse campo) também é bastante usado, mas necessita dos parênteses. Para evitar o desgaste dos dedos dos programadores com a digitação excessiva de parênteses, criou-se uma sintaxe alternativa para esse caso, com um novo operador, ->, que tem à sua esquerda um ponteiro para estrutura e a sua direita o nome de um campo dessa estrutura. A notação p->dia é equivalente a (*p).dia, e é bem mais comum. Como exemplo, a função que vimos anteriormente poderia, em vez de retornar a estrutura data, receber um ponteiro para uma estrutura a ser alterada:

void data_desde_ano_novo(int ano, int dias, data *pdata)
{
  // se tem mais dias do que cabe no ano, avança para o ano seguinte
  while (dias > dias_no_ano(ano)) {
    dias -= dias_no_ano(ano);
    ano++;
  }
  pdata->ano = ano;   // poderia ser também   (*pdata).ano = ano;
  // chuta o mês para janeiro
  pdata->mes = mes;
  // se o número de dias é maior que o mês, avança para o mês seguinte
  while (dias > dias_no_mes(pdata->mes, pdata->ano)) {
    dias -= dias_no_mes(pdata->mes, pdata->ano);
    pdata->mes++;
  }
  pdata->dia = dias;
}

Misturando tipos compostos

Uma estrutura pode conter campos de qualquer tipo, inclusive outras estruturas, vetores ou ponteiros. Por exemplo, um registro que contém dados a respeito de um aluno poderia ser:

  typedef struct {
    char nome[50];
    int matricula;
    data nascimento;
    data ingresso;
    int notas_trab[10];
    float notas_provas[2];
    float nota_exame;
    float media;
  } aluno;

  void calcula_media(aluno *a)
  {
    float soma_trab = 0;
    for (int i=0; i<10; i++) {
      soma_trab += a->notas_trab[i];
    }
    // calcula a media entre trabalhos e provas
    float media_trab = soma_trab / 10;
    float media_provas = (a->notas_provas[0] + a->notas_provas[1]) / 2;
    float media = (media_trab + media_provas) / 2;
    // altera a media de acordo com a avaliacao subjetiva do aluno
    srand(a->nascimento.dia * a->nascimento.ano);
    media += (rand() % 2000)/1000.0 - 1;
    // verifica se tem exame
    if (media < 7) {
       media = (media + a->nota_exame) / 2;
    }
    // atualiza a media final no registro
    a->media = media;
  }

  int main() {
    aluno joao, maria;
    joao = le_aluno_do_arquivo();
    maria = le_aluno_do_arquivo();
    calcula_media(&joao);
    calcula_media(&maria);
    imprime_aluno(joao);
    imprime_aluno(maria);
    return 0;
  }

Pode-se ter um vetor de registros:

  void calcula_medias(int n, aluno alunos[n]) {
    for (int i=0; i<n; i++) {
      calcula_media(&alunos[i]);
    }
  }

O vetor pode ser alocado dinamicamente:

  int main()
  {
    aluno *alunos;
    alunos = malloc(25 * sizeof(aluno));
    if (alunos == NULL) { /*...*/ }
    le_registros_de_arquivo(25, alunos);
    calcula_medias(25, alunos);
    // ...
  }

Ou, de forma mais dinâmica e dentro de uma estrutura:

  int main()
  {
    struct {
      int nalunos;
      aluno *alunos;
      // ...
    } classe;

    classe.nalunos = descobre_quantos_alunos_sao();
    classe.alunos = malloc(classe.nalunos * sizeof(aluno));
    // ...
  }

Organização de programas maiores

Vimos uma das princiapis formas de se organizar um programa, dividindo-o em funções. Essa divisão do programa ajuda a lidar com a complexidade do código, e com a reutilização de partes dele. Mas conforme os programas crescem, só isso não é suficiente.

Uma forma complementar de lidar com o aumento da complexidade é separar o programa em módulos maiores, tipicamente constituídos por um grupo de funções relacionadas. Em geral, um grupo de funções que atuam de forma coordenada o fazem sobre dados sobre os quais essas funções atuam. Então, geralmente um módulo inclui a definição de tipos de dados e de funções para manipulá-los. Assim, cria-se uma abstração de maior nível no programa, permitindo ao programador trabalhar com o “encaixe” de blocos maiores na construção de seu programa.

Por exemplo, digamos que se faça um programa que precisa manipular datas. Facilitaria a implementação desse programa se todos os tipos de dados e funções para a manipulação de datas fossem organizadas e escritas uma vez só, e o resto do programa simplesmente usasse essas funções conforme a necessidade, sem precisar o programador pensar em detalhes como anos bissextos ou o número de dias de cada mês em todos os pontos do programa em que precisasse fazer alguma manipulação envolvendo datas.

Para aumentar o isolamento dessa parte do código, em geral separa-se cada um desses módulos em arquivos independentes. Então, nesse exemplo, teria um arquivo (datas.c, por exemplo), que conteria todas as funções relacionadas a datas. Esse arquivo conteria todas as funções relacionadas a manipulação de datas, e esse código só precisaria ser revisto caso apresente problemas ou caso se queira ampliar (ou reduzir ou reorganizar) o conjunto de funções. Os detalhes dessa parte da implementação podem ser esquecidos enquanto se trabalha no restante do programa, diminuindo a complexidade e simplificando esse trabalho. A realização de um programa passa a ser então uma tarefa de dividí-lo nesses módulos, definir como se interage com cada módulo (chamado de interface de acesso ao módulo) e depois a implementação de cada módulo.

A interface de acesso a um módulo deve ser bem definida, para permitir que se possa usar o módulo de forma simples, sem a necessidade de se conhecer seus detalhes internos. Para poder usar um módulo o restante do programa só deve necessitar conhecer essa interface.

Para deixar mais clara a separação entre interface e implmentação, geralmente a implementação de um módulo é dividida em dois arquivos, um contendo a implementação do módulo (arquivo com extensão .c) e outro contendo a declaração da interface de acesso ao módulo (arquivo com extensão .h). Para facilitar a identificação, esses dois arquivos têm o mesmo nome (exceto a estensäo). No arquivo de interface, tem-se os tipos de dados e as declaração das funções desse módulo que podem ser usadas pelos demais módulos do programa. Em um arquivo que vá usar funções de determinado módulo, deve-se incluir o arquivo de interface, com um comando #include. Para diferenciar entre a inclusão de módulos internos do programa e módulos externos (tipicamente fornecidos pelo sistema), ao invés de se colocar o arquivo a incluir entre < e >, usa-se aspas ". Usa-se #include <stdio.h> para arquivos de interface externos e #include "datas.h" para arquivos de interface internos ao programa. Para garantir que o que está descrito no arquivo de interface corresponda ao que está no arquivo de implementação correspondente, esse último também inclui o seu arquivo de interface no início.

A implementação de um módulo de cálculo de datas poderia ser dividida em dois arquivos, um com a interface, chamado de data.h:

// data.h
//
// Módulo de tratamento de datas.
// Define um tipo de dados para representar uma data e 
// declara funções para manipular dados desse tipo.

#include <stdbool.h>  // necessita de bool para a declaracao de algumas funcoes

// o tipo de dado para representar é data_t
typedef struct {
  int dia;
  int mes;
  int ano;
} data_t;

// Compara duas datas.
// Recebe duas datas como argumento, retorna 0 se as datas são iguais,
// -1 se a primeira data for anterior à segunda e +1 caso contrário.
int data_compara(data_t d1, data_t d2);

// Verifica se um ano é bissexto
// Recebe o ano, retorna verdadeiro se é bissexto
bool data_bissexto(int ano);

// Calcula o número de dias no ano
// Recebe o ano como argumento, 
// retorna o número de dias que esse ano tem (365 ou 366)
int data_dias_no_ano(int ano);

// Calcula uma data que está a tantos dias de outra
// Recebe uma data e um número de dias (que pode ser positivo ou negativo)
// Retorna a data que está a esse número de dias de distância da data fornecida
data_t data_soma_dias(data_t d, int dias);

// etc

A implementação desse módulo seria colocada em um arquivo data.c:

// data.c
//
// implementa funções de manipulaçãio de datas

#include "data.h"

int data_compara(data_t d1, data_t d2)
{
  if (d1.ano < d2.ano) return -1;
  if (d1.ano > d2.ano) return 1;
  if (d1.mes < d2.mes) return -1;
  if (d1.mes > d2.mes) return 1;
  if (d1.dia < d2.dia) return -1;
  if (d1.dia > d2.dia) return 1;
  return 0;
}

bool data_bissexto(int ano)
{
  // ...
}

// ...

Na hora de compilar, deve-se compilar juntamente os arquivos .c que faze parte do programa (ou usar uma IDE para fazê-lo).

Módulo auxiliar de depuração de alocações de memória

Os arquivos abaixo (mem.h e mem.c) contêm funções para auxiliar na depuração de programas que usam alocação e liberação dinâmica de memória. Substitua em seu programa as chamadas a malloc por chamadas a m_aloca, e chamadas a free por chamadas a m_libera. Antes de usar uma área de memória que deve ter sido alocada dinamicamente, chame antes m_verif passando como argumento o ponteiro para essa área de memória, para fazer uma verificação simples se ele efetivamente está apontando para uma área alocada dinamicamente e ainda não liberada. A qualquer momento, pode chamar m_relat para saber o número de áreas alocadas e liberadas de memória que seu programa tem, ou m_relatorio_grande para uma listagem de todas as áreas alocadas pelo seu programa.

O arquivo ex_mem.c tem um exemplo simples de uso de algumas dessas funções.

// -----
// mem.h
// -----

// Conjunto de funções simples para auxiliar na depuração na alocação de memória
// Use-as no lugar de malloc e free
//                                             l12020a, ufsm


#ifndef _mem_h_
#define _mem_h_

#include <stdbool.h>

// funcoes auxiliares -- nao use
void *m_aloca_mesmo(int nbytes, char *funcao, int linha);
void m_libera_mesmo(void *ptr, char *funcao, int linha);

// m_aloca -- use em vez de malloc.
// ela vai coletar as informacoes de alocacao
#define m_aloca(nbytes) m_aloca_mesmo(nbytes, (char *)__func__, __LINE__)

// m_libera -- use em vez de free.
// ela vai coletar informacoes de alocacao
#define m_libera(ptr) m_libera_mesmo(ptr, (char *)__func__, __LINE__)

// use para verificar se ptr é uma regiao que foi alocada com m_aloca e
// aind nao liberada
bool m_verif(void *ptr);

// chame esta funcao para imprimir um relatorio resumido de alocacoes
void m_relat(void);

// chame esta funcao para um relatorio mais completo
void m_relatorio_grande(void);

#endif // _mem_h_
// -----
// mem.c
// -----
//
// implementacao de funções para auxilio simples a alocacao de memoria

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

// descritor de uma regiao de memoria alocada
typedef struct {
  void *ptr;      // regiao alocada
  int nbytes;     // tamanho da regiao
  char *f_aloca;  // funcao onde foi alocada
  int l_aloca;    // linha onde foi alocada
  char *f_libera; // funcao onde foi liberada
  int l_libera;   // linha onde foi liberada
} aloc_t;

#define MAX_ALOCACOES 100000 // numero maximo de alocacoes rastreadas

aloc_t alocacoes[MAX_ALOCACOES]; // tabela com todas as alocacoes
int n_aloc = 0;   // número de alocações registradas
int n_libera = 0; // número de liberações registradas

void guarda_ptr(void *ptr, int nbytes, char *funcao, int linha)
{
  if (n_aloc >= MAX_ALOCACOES) {
    printf("estourou capacidade do registrador de alocações.\n");
    return;
  }
  alocacoes[n_aloc].ptr = ptr;
  alocacoes[n_aloc].nbytes = nbytes;
  alocacoes[n_aloc].f_aloca = funcao;
  alocacoes[n_aloc].l_aloca = linha;
  alocacoes[n_aloc].f_libera = NULL;
  alocacoes[n_aloc].l_libera = 0;
  n_aloc++;
}

aloc_t *acha_ptr(void *ptr)
{
  for (int i=0; i<n_aloc; i++) {
    if (alocacoes[i].ptr == ptr) return &alocacoes[i];
  }
  return NULL;
}

void libera_ptr(aloc_t *reg, char *funcao, int linha)
{
  reg->f_libera = funcao;
  reg->l_libera = linha;
  n_libera++;
}


void *m_aloca_mesmo(int nbytes, char *funcao, int linha)
{
  void *ptr = malloc(nbytes);
  if (ptr != NULL) {
    guarda_ptr(ptr, nbytes, funcao, linha);
  }
  return ptr;
}

void m_libera_mesmo(void *ptr, char *funcao, int linha)
{
  m_verif(ptr);
  aloc_t *reg = acha_ptr(ptr);
  if (reg != NULL) {
    libera_ptr(reg, funcao, linha);
    // free(ptr);  // descomenta esta linha para liberar de verdade a memoria
  } else {
    printf("tentativa de liberar ponteiro não alocado\n");
    printf("funcao %s, linha %d\n", funcao, linha);
  }
}

// use para verificar se ptr é uma regiao que foi alocada com m_aloca e
// ainda nao liberada
bool m_verif(void *ptr)
{
  if (ptr == NULL) {
    printf("uso de ptr NULL\n");
    return false;
  }
  aloc_t *reg = acha_ptr(ptr);
  if (reg == NULL) {
    printf("uso de ptr nao alocado\n");
    return false;
  }
  if (reg->f_libera != NULL) {
    printf("uso de ptr ja liberado (na funcao %s, linha %d)\n",
      reg->f_libera, reg->l_libera);
    return false;
  }
  return true;
}

// chame esta funcao para iimprimir um relatorio resumido de alocacoes
void m_relat(void)
{
  printf("Total de alocações: %d  total de liberações: %d\n", 
         n_aloc, n_libera);
}

// chame esta funcao para um relatorio mais completo
void m_relatorio_grande(void)
{
  m_relat();
  printf("Alocações:\n");
  for (int i=0; i<n_aloc; i++) {
    if (i%20 == 0) {
      printf("    id nbytes              alocado em             liberado em\n");
    }
    printf("%6d%7d%19s:%04d", i, alocacoes[i].nbytes,
        alocacoes[i].f_aloca, alocacoes[i].l_aloca);
    if (alocacoes[i].f_libera == NULL) {
      printf("%24s\n", "NAO LIBERADO");
    } else {
      printf("%19s:%04d\n", alocacoes[i].f_libera, alocacoes[i].l_libera);
    }
  }
}