Linguagens e Ambientes de Programação (2022/2023)



Teórica 17 (04/mai/2023)

Comparação dos modelos de memória do C e Java.
Revisão das operações básicas de atribuição e comparação.
Revisão do operador &.
Revisão do essencial sobre as funções.
O qualificador de tipo const.
Portabilidade - Independência da máquina.
Inseguranças da linguagem C.



Comparação dos modelos de memória do C e Java

Relativamente aos tipos primitivos (inteiros, reais, etc.) não há qualquer diferença da forma de lidar com os valores na memória.

Relativamente aos tipos não-primitivos (registos, vetores e objetos) as diferenças são grandes:

  1. Em Java todos os valores de tipos não básicos designam-se como objetos e: (1) são alocados dinamicamente; (2) nas variáveis guardam-se apenas referencias para os objetos (que residem fora das variáveis); (3) são passadas referencias para as funções.

  2. O C caracteriza-se por suportar duas variantes, no tratamento dos tipos não básicos:

    1. Na variante mais simples, mais fácil de usar, e mais segura, os valores são tratados da mesma forma que os tipos primitivos. Este modelo é radicalmente diferente do modelo do Java. Portanto, os valores: (1) não são alocados dinamicamente; (2) são guardados diretamente nas variáveis; (3) são passados "por valor" para as funções, ou seja através de cópia. Existe apenas um tratamento especial para os vetores, no pormenor de serem passados através dum apontador.

    2. Na variante mais complexa e menos segura, que só se usa pontualmente quando há uma boa justificação, é possível usar um modelo parecido com o do Java. Por isso, em C, também existe a possibilidade de (1) alocar dinamicamente; (2) numa variável guardar apenas o apontador para o valor dinâmico (que reside fora da variável); (3) transmitir para as funções apontadores.
Como se vê, em C existe liberdade de escolha na forma de lidar com os valores de tipos não primitivos. O programador fica com a responsabilidade de, em cada situação, para cada variável, decidir qual a forma de atuar. Geralmente, recomenda-se o uso da primeira forma, a não ser que existam razões especiais para alocar os valores dinamicamente: por exemplo a necessidade de criar um vetor de tamanho não pré-definido, ou a necessidade de representar listas ligadas.

A vida do programador de Java tem menos dilemas, porque não existe liberdade de escolha.

Existe um pormenor que, muitas vezes confundem os programadores de usam simultaneamente C e Java. Tratar-se da sintaxe usada para aceder ao campo de um registo. Em C, a sintaxe, v.c, pressupõe que a variável v contém um registo guardado diretamente no seu interior. Mas em Java, a mesma sintaxe, v.c, pressupõe que a variável v contém uma referência para um objeto guardado no exterior. Portanto: a mesma sintaxe, conceitos diferentes! Se desejarmos em C lidar com os registos através de apontadores, a sintaxe é diferente: v->c.


Revisão das operações básicas de atribuição e comparação

Esquecendo agora o Java e regressando apenas ao C, vamos relembrar a que tipos de dados as operações básicas de atribuição e comparação se podem aplicar. É importante fazer isso para ficarmos com as ideias bem sistematizadas.

Atribuição =

A operação de atribuição pode ser usada para copiar valores dos seguintes tipos:

A operação de atribuição não se aplica a:

Mas no caso das strings (um caso particular dos vetores) existe uma operação de cópia que se chama strcpy.

Se precisarmos de efetuar atribuições com vetores ou com ficheiros, temos de ser nós a programar essa operação.

Igualdade == e desigualdades !=, <, >, <=, >=

Estas operações podem ser usada para comparar valores dos seguintes tipos: Estas operações não se aplicam a:

Mas no caso das strings (um caso particular dos vetores), a função strcmp permite comparar strings.

Se precisarmos de comparar registos, vetores ou ficheiros, temos de ser nós a programar as operações.


Revisão do operador &

O operador & permite obter um apontador para uma variável.

No caso da função de leitura scanf o operador & é usado nos argumentos que sejam variáveis de tipo int, double e char mas não de tipo string.

O operador & também pode ser usado para representar o segmento final duma string, a começar na posição de índice i: &str[i]


Revisão do essencial sobre as funções

Na linguagem C todo o código executável está contido em funções.

Para obter um programa bem organizado e legível, o segredo está em identificar e definir quais as funções necessárias para escrever o programa. Cada função deve ser simples e resolver apenas um pequeno subproblema. Um bom programa é geralmente constituído por um grande número de funções pequenas.

Uma função pode ter qualquer número de argumentos, incluindo zero argumentos. Para indicar ausência de argumentos, usa-se o tipo void no cabeçalho da função, na zona dos argumentos.

Uma função pode ter zero ou um resultados. Para indicar ausência de resultado, usa-se o tipo void no cabeçalho da função, na zona do resultado.

Quando se define uma função é preciso indicar o nome da função, o tipo e nome dos argumentos e o tipo do resultado. Um exemplo:

As funções podem produzir efeitos laterais para além de calcular um resultado.

No caso extremo duma função que retorne void, ela não produz qualquer resultado e apenas efeitos laterais. Exemplo:

Parâmetros são passados "por valor"

Nas funções, a linguagem C suporta apenas passagem de parâmetros por valor. Se quisermos definir funções com parâmetros de saída é necessário passar (por valor, claro) apontadores como argumentos (isto já foi estudado na secção sobre apontadores, numa aula passada).

Eis mais um exemplo duma função com parâmetro de saída, em que esse parâmetro é usado de forma sofisticada em diversos pontos do corpo da função - tente perceber bem o exemplo, pois ele está cheio de detalhes subtis e instrutivos:

Já sabemos duma aula anterior que o nome dum vetor é sempre um apontador constante para a sua primeira posição. Este facto determina o que acontece quando se passa um vetor como argumento para uma função. O que realmente é passado é o tal apontador. Logo, um argumento-vetor é um parâmetro de saída (ou se entrada-saída, conforme a forma como for usado).

Outra consequência dos vetores serem simplesmente passados como um apontadores é o facto da função não saber o tamanho do vetor recebido. Por causa disso, o programador geralmente passa o tamanho do vetor num argumento suplementar. Repare que isto até é uma vantagem, pois assim a mesma função consegue processar vetores de diferentes tamanhos.

Antes da norma C89, valores de tipos registo ou tipos união não podiam ser passados diretamente como argumentos de funções; só se permitia passar apontadores para eles.

Chamadas para a frente

O C valida o tipo dos argumentos nas chamadas de funções. Normalmente só é possível chamar funções definidas antes, porque as funções definidas a seguir, o compilador ainda não as conhece.

Para chamar uma função definida mais à frente, é preciso declarar previamente o cabeçalho da função usando um chamado protótipo. Exemplo:

Funções sem argumentos

As funções com zero argumentos devem ser definidas com a palavra void na posição dos argumentos, tal como na função f anterior. Curiosamente, quando a lista de argumentos é vazia, isso significa que a função pode ser chamada com qualquer número de argumentos, não sendo validada qualquer chamada da função (e depois a funções consegue aceder aos parâmetros não declarados usando código assembly).

Função main

A função main, onde o programa começa a correr, pode ser escrita usando qualquer dos três cabeçalhos seguintes:

Parâmetros funcionais

Usando apontadores para funções, em C é possível escrever funções com parâmetros funcionais. Para adicionar ao módulo LinkedList uma função listSearch para procurar o primeiro valor da lista com uma propriedade dada, faz-se assim. A propriedade é especificada usando uma função booleana. Pode ser testada assim:

Mas note que a possibilidade de passar função por parâmetro é pouco poderosa, porque em C não existe aninhamento de função, mas só funções globais.



O qualificador de tipo const

O qualificador const pode ser usado nos tipos da linguagem C para indicar que determinadas variáveis ou determinadas zonas de memória não podem ser alterados depois de inicializado.

Há duas vantagens em usar este qualificador:

O qualificador const é de utilização muito flexível. Veja os seguintes exemplos: A leitura destes tipos nem sempre é simples. Veja as regras:

Um caso particular importante: se um argumento duma função tem o atributo const (e.g. "const int a") isso significa que se trata dum parâmetro de entrada que não pode ser modificado no corpo da função.



Portabilidade - Independência da máquina

Apesar da possibilidade de usar diretamente os recursos duma máquina, a linguagem C não está comprometida com nenhuma arquitetura particular e até encoraja a escrita de programas portáveis, ou seja programas que funcionam em qualquer máquina.

Mas a portabilidade não se obtém automaticamente e o programador tem de se preocupar com essa questão.

Problema 1: tamanho dos valores

O tamanho dos valores de diversos tipos variam de implementação para implementação e a norma da linguagem especifica apenas valores mínimos. Por exemplo, uma variável de tipo int precisa de ter um mínimo de 16 bits (mas tem muitas vezes 32 bits) e uma variável de tipo long precisa de ter um mínimo de 32 bits (mas tem muitas vezes 64 bits).

Nunca se deve assumir que um inteiro ou um long tem um tamanho particular. Se for preciso conhecer o tamanho, então ele deve ser obtido através da função sizeof.

Para não se ter de pensar no tamanho dos valores, por vezes usam-se os seguintes tipos de biblioteca:

Em todo o caso, sempre que o mínimo de 16 bits for suficiente, deve usar-se o tipo int, pois é normalmente o tipo inteiros mais eficiente da máquina.

O C99 introduziu um módulo com header stdint.h para ajudar nas situações em que precisarmos de inteiros com um número de bits conhecido à partida. Nesse ficheiro estão definidos os tipos int8_t, int16_t, int32_t, int64_t. Se precisarmos dum inteiro com tamanho suficiente para guardar um apontador, podemos usar o tipo intptr_t.

Problema 2: Arrumação dos campos nos registos

A arrumação dos campos num registo é dependente do compilador. Quando se enviam registos através da rede, dum programa para outro, convém implementar um mecanismo de serialização.

Problema 3: ordem dos bytes (Big endian/Little endian)

Os diversos bytes que constituem um inteiro podem ser guardados por duas ordens diferentes, consoante arquitetura do computador. Quando se enviam valores através da rede, convém usar um mecanismo de serialização que leve isso em conta.



Inseguranças da linguagem C

A linguagem C tem diversas inseguranças relativamente a erros de tipo: A linguagem C também tem diversas inseguranças relativamente a erros de lógica básicos:

Ferramentas que ajudam a aumentar a proteção em C

Para detetar problemas, chamar o compilador de C com todos os warnings ligados (cc -Wall) já ajuda unm pouco, mas em geral recomenda-se a utilização duma ferramenta especializada para detetar código duvidoso.

splint

Em 1979 foi criado um verificador chamado lint que passou a ser distribuído com todas as versões do Unix.

Uma versão melhorada, atualmente em uso, chama-se splint:

gdb

O Gnu Debugger trabalha com linguagens implementadas nativamente e tem um funcionalidade extensíssima. Mostramos só como usar o gdb para descobrir qual a função onde um programa está a rebentar.

O seguinte programa está errado:

O gdb permite mostrar o conteúdo da pilha de execução no momento do estoiro, o que geralmente é muito útil para descobrir as razões do erro.

valgrind

Uma situação dramática em C e C++ é quando, numa fase adiantada de desenvolvimento, um programa grande começa a rebentar com problemas de acesso à memória. Quando não há a mínima ideia sobre qual possa ser a origem do problema, a ferramenta valgrind pode ser a salvação. Permite executar o programa executável dentro duma máquina virtual que valida todos os acessos à memória, conseguindo detetar: Para usar o Valgring, faça assim: Compile o programa da seguinte forma: e depois corra o programa assim: Exercício: Considere o seguinte programa em C. Este programa não tem erros sintáticos e portanto compila. No entanto tem diversos erros de lógica que os programadores fazem frequentemente.
  • 1 - Depois descubra e explique todos os erros deste programa.

  • 2 - Use a ferramenta Valgrind para descobrir automaticamente os erros anteriores.

    Vídeos antigos

    Estes vídeos poderão estar um pouco desatualizados, pois foram feitos no contexto duma edição anterior do LAP. Contudo, partes dos vídeos poderão continuar a ter alguma utilidade.

    #--- 30 20