Programação Computacional para Engenharia – 2017.1

Explicações Adicionais


Última atualização: 2017-06-02 (Funções para Alocação Dinâmica de Memória).

Operadores Lógicos

A linguagem C possui, além dos operadores aritméticos + - * / % e relacionais == != < <= > >=, os operadores lógicos && || ! ("e", "ou" e "não", respectivamente), que servem para criar condições compostas.

Assim, por exemplo, suponha que desejamos escrever um programa que leia do usuário um número inteiro e que em seguida imprima na tela se esse número está ou não no intervalo de 1 a 10. Certamente, isso poderia ser feito usando apenas o comando "if":

#include <stdio.h>

int main (void)
  {
  int n;
  printf("Digite um número inteiro: ");
  scanf("%d", &n);
  if (n < 1)
    { printf("O número não está no intervalo de 1 a 10.\n"); }
  else
    {
    if (n > 10)
      { printf("O número não está no intervalo de 1 a 10.\n"); }
    else
      { printf("O número está no intervalo de 1 a 10.\n"); }
    } // else
  } // main
Porém, é mais conveniente escrever esse programa usando o operador lógico && ("e")...
#include <stdio.h>

int main (void)
  {
  int n;
  printf("Digite um número inteiro: ");
  scanf("%d", &n);
  if (n >= 1 && n <= 10)
    { printf("O número está no intervalo de 1 a 10.\n"); }
  else
    { printf("O número não está no intervalo de 1 a 10.\n"); }
  } // main
... ou o operador lógico || ("ou"):
#include <stdio.h>

int main (void)
  {
  int n;
  printf("Digite um número inteiro: ");
  scanf("%d", &n);
  if (n < 1 || n > 10)
    { printf("O número não está no intervalo de 1 a 10.\n"); }
  else
    { printf("O número está no intervalo de 1 a 10.\n"); }
  } // main

OBSERVAÇÃO: os operadores && e || só avaliam a condição da direita se a avaliação da condição da esquerda não for suficiente para dar o resultado da expressão completa.

Assim, por exemplo, uma expressão da forma A && B só é verdadeira se tanto a condição A quanto a condição B forem verdadeiras, mas A é sempre avaliada primeiro, e, caso A seja falsa, já se sabe que A && B também é falsa, e por isso a condição B não é sequer avaliada.

De forma análoga, uma expressão da forma A || B é verdadeira se A ou B forem verdadeiras (incluindo o caso em que ambas são verdadeiras), mas A é sempre avaliada primeiro, e, caso A seja verdadeira, já se sabe que a expressão A || B também é verdadeira, e por isso a condição B não é sequer avaliada.

O operador ! ("não") também pode ser utilizado no programa acima:
#include <stdio.h>

int main (void)
  {
  int n;
  printf("Digite um número inteiro: ");
  scanf("%d", &n);
  if (!(n >= 1 && n <= 10))
    { printf("O número não está no intervalo de 1 a 10.\n"); }
  else
    { printf("O número está no intervalo de 1 a 10.\n"); }
  } // main
Os operadores lógicos podem ser encadeados para criar condições compostas de várias condições menores. Você também pode usar parênteses para garantir a correta estrutura da expressão ou para melhorar a legibilidade do código-fonte. Assim, por exemplo, A && B || C é o mesmo que (A && B) || C, pois && tem maior precedência que ||, e nesse caso os parênteses são opcionais (mas você pode escolher escrevê-los para deixar a condição mais clara); entretanto, se a expressão desejada for A && (B || C), então o uso dos parênteses é obrigatório, para garantir a correta estrutura da expressão.

Atribuição e Inicialização

Em C, = é o operador de atribuição, que serve para atribuir um valor a uma variável:

#include <stdio.h>

int main (void)
  {
  int n; // Valor de "n" desconhecido a priori, mas pode ser impresso na tela.
  printf("n: %d\n", n);
  n = 5;
  printf("n: %d\n", n);  // Agora, "n" vale 5.
  n = 8;
  printf("n: %d\n", n);  // Agora, "n" vale 8.
  n = (n + 2)/2 - n;
  printf("n: %d\n", n);  // Agora, "n" vale (8+2)/2 - 8, ou seja, -3.
  }

O operador = também serve para inicializar uma variável, isto é, atribuir a ela um valor inicial.

#include <stdio.h>

int main (void)
  {
  int n = 9; // Inicialização: "n" já começa valendo 9.
  printf("n: %d\n", n);
  n = 5;     // Atribuição: agora, "n" vale 5.
  printf("n: %d\n", n);
  int m = (10*n)/2;  // Inicialização: "m" começa valendo (10*5)/2, ou seja, 25.
  printf("n: %d, m: %d\n", n, m);
  }

Complemento de 2

Observações:
  1. Ao utilizar a operação de complemento de 2, "2k − N", é importante lembrar que o número N da fórmula é o número natural correspondente à sequência de bits do número cujo inverso aditivo é buscado (e portanto que N não é o próprio número cujo inverso aditivo é buscado, no caso de esse número ser negativo).

    Exemplo: desejamos descobrir o número representado por "11101". Como o primeiro bit é 1, então trata-se de um número negativo "−y". Como y = −(−y), nós podemos descobrir "y" calculando o complemento de 2 de "11101". Isso pode ser feito de 2 maneiras:

    1. Como o número natural N cuja representação binária é "11101" é 29, então y = 25 − 29 = 32 − 29 = 3.

    2. Invertendo os bits de "11101", obtemos "00010". Adicionando 1, obtemos "00011", que é a representação binária de 3.

    Nós descobrimos, portanto, que "11101" representa o número −3. Entretanto, observe agora: se "11101" representa −3, então, quando nós utilizamos a operação de complemento de 2 sobre "11101", nós desejávamos descobrir o inverso aditivo de −3; porém, seria errôneo realizar o cálculo 25 − (−3) para isso. O erro consiste em utilizar −3 no cálculo; conforme explicado acima, deve ser utilizado o número 29, que é o número natural correspondente à sequência "11101".

  2. A regra de que o inverso aditivo de um número é obtido por meio da operação "2k − N" tem uma exceção: o caso em que a representação do número é "1000...000".

    Exemplo: qual é o número representado por "10000"? Como o primeiro bit é 1, então trata-se de um número negativo. Porém, se tentarmos calcular o complemento de 2 de "10000", eis o resultado: invertendo os bits, obtemos "01111"; adicionando 1, obtemos "10000", que é a própria sequência original!

    O número representado por uma sequência "1000...000" pode ser calculado de maneira indireta. Retomando, por exemplo, o caso da sequência "10000":

    Na representação por complemento de 2, a sequência "1000...000" representa sempre o número negativo de maior valor absoluto: –2k−1. Dentre as 3 representações de inteiros em C (sinal-e-magnitude, complemento de 1 e complemento de 2), esse é o único caso em que um número −y é representável e o número "y" não o é.

    Você deve lembrar que "1000...000" é a exceção da regra do complemento de 2. Além disso, para lembrar que o número representado por essa sequência é –2k−1, você pode:

    1. Fazer o cálculo indireto ilustrado acima para "10000".

    2. Lembrar que, para "k" bits, há 2k números representáveis: metade não-negativos e metade negativos. Mais especificamente, os 2k−1 números não-negativos são 0, 1, 2, ..., 2k−1 − 1 (começando de 0), e os 2k−1 negativos são −1, −2, ..., −2k−1 (começando de −1).


Limites e Transbordo de int

Conforme estudamos, uma variável do tipo int pode armazenar valores num intervalo limitado. Os limites desse intervalo estão nas constantes INT_MIN e INT_MAX, definidas em <limits.h>.

Para o tipo unsigned int, a aritmética é modular, e não existe erro em se incrementar uma variável que esteja armazenando o maior valor representável. Já para o tipo int, a situação é diferente: se o resultado de uma expressão cai fora do intervalo dos números representáveis, trata-se de uma situação de transbordo (overflow), e o comportamento resultante do programa é indefinido. Isso significa que, em princípio, o programa poderia até mesmo terminar com um erro.

Na prática, porém, um comportamento comum para transbordo de inteiros é que nenhum erro ocorra, e que a aritmética típica seja aplicada aos bits da expressão. Assim, por exemplo, se uma variável inteira possuísse apenas 4 bits, estivesse armazenando o valor 7 ("0111") e fosse incrementada, poderia então vir a assumir o valor −8 ("1000"), supondo-se a representação por complemento de 2.


Conversões entre int e unsigned int

Em C, é possível atribuir um valor do tipo int a uma variável do tipo unsigned int, e vice-versa, com as seguintes garantias:

  1. Se o valor pode ser representado no tipo de destino, então o valor não é alterado.

    Esse é o caso dos valores não-negativos que admitem representação num int:

    int i = 30000;
    unsigned int u = i; // u == 30000, como esperado.
    u = 20000;
    i = u;              // i == 20000, como esperado.
    
  2. Se o valor atribuído não pode ser armazenado no tipo de destino, então:

    1. Caso o tipo de destino seja unsigned int, então o valor sendo atribuído é negativo. Nesse caso, esse valor é somado com 2k (sendo "k" o número de bits da representação), e o resultado (positivo) será armazenado na variável de destino.

      Observe que, caso o tipo int utilize a representação de complemento de 2 (que é o caso típico hoje em dia), a explicação acima significa que os bits da expressão int serão simplesmente copiados para a variável unsigned int, onde passarão a representar um valor positivo.

    2. Caso o tipo de destino seja int, então o valor sendo atribuído é um número positivo maior do que um int pode armazenar, e nesse caso o resultado depende da arquitetura.

      Na prática, um resultado possível (e típico?) é que, novamente, os bits da expressão original serão simplesmente copiados para a variável de destino, onde passarão a representar um valor negativo.


Funções sem Retorno

Assim como existem funções que não possuem parâmetros e portanto não recebem argumentos, existem também funções que não retornam valores; elas são definidas especificando-se o tipo de retorno como void:

#include <stdio.h>

void boas_vindas (void)  // Sem retorno, sem parâmetros.
  {
  printf("Olá! Este programa calcula o quadrado de um racional.\n\n");
  }

double ler_num (void)  // Com retorno, sem parâmetros.
  {
  printf("Digite um número racional: ");
  double x;
  scanf("%lg", &x);
  return x;
  }

void imp_quad (double x)  // Sem retorno, com parâmetros.
  {
  printf("O quadrado de %g é %g.\n", x, x*x);
  }

int main (void)  // Com retorno, sem parâmetros.
  {
  boas_vindas();
  double num = ler_num();
  imp_quad(num);
  }

Como ilustrado acima, a maneira de chamar uma função que não retorna valor é a mesma de chamar uma função que retorna valor, com uma diferença importante: enquanto a chamada de uma função que retorna valor denota o valor retornado pela função, a chamada de uma função que não retorna valor não denota valor algum, e portanto não pode ser utilizada num contexto onde um valor é esperado:

double num1 = ler_num();     // Correto   (esperado um double).
double num2 = boas_vindas(); // Incorreto (esperado um double).
double num3 = imp_quad(3.1); // Incorreto (esperado um double).

Conversões Implícitas Adicionais

Em C, além das conversões implícitas que ocorrem quando os operandos dos operadores aritméticos (+ − * / %) e relacionais (> >= < etc) têm tipos diferentes, também ocorrem conversões implícitas nos seguintes contextos:

  1. Atribuição e Inicialização:

    double d = 3.14; // ok: 3.14 tem tipo double
    int i = 7;       // ok: 7 tem tipo int
    i = (int) d;     // ok: "(int) d" tem tipo int
    d = (double) i;  // ok: "(double) i" tem tipo double
    i = 3.14;        // ok: 3.14 implicitamente convertido para int
    d = 3;           // ok: 3 implicitamente convertido para double
    double d2 = 3;   // ok: 3 implicitamente convertido para double
    

    Atenção: lembre que tais conversões são utilizadas apenas para efeito do aproveitamento do valor de um termo numa expressão; elas não alteram os tipos das variáveis (os quais, na realidade, nunca mudam).

  2. Inicialização de Parâmetros:

    double ao_quadrado (double x) { return x*x; }
    
    ...
    
    double q = ao_quadrado(3);  // ok: 3 implicitamente convertido para double
    
  3. Retorno de Funções:

    int sempre_3 (void)
      {
      return 3.14;  // ok: 3.14 implicitamente convertido para int
      }
    

Funções para Alocação Dinâmica de Memória

Em C, as funções malloc, calloc e realloc permitem a alocação de memória em tempo de execução, e a função free permite a devolução dessa memória ao sistema; todas estão declaradas em <stdlib.h>.

O retorno das 3 funções de alocação acima segue a mesma regra:

  1. Se a alocação foi bem-sucedida, é retornado um ponteiro (de tipo void*) apontando para o início da memória que foi alocada.

  2. Se a alocação não pôde ser realizada (sem memória suficiente), então é retornado um ponteiro nulo.

Deve-se sempre testar se o ponteiro retornado pelas funções de alocação é nulo ou não. Além disso, a memória alocada com essas funções deve ser posteriormente desalocada, seja via "free" ou "realloc".

As funções acima são explicadas a seguir: