Guião da 1ª aula teórica

Sumário

  1. Modularização: física e em pacotes.
  2. Noções de modularização física e compilação separada:
    1. Vantagens.
    2. Fases da construção dum programa (pré-processamento, compilação, fusão).
    3. Compilação: análises lexical, sintáctica e semântica, optimização e geração de código máquina.
    4. Noções sobre ficheiros de interface (.H), fonte (.C), objecto (.o), biblioteca ou arquivo (.a), e executável.
    5. Regra da definição única. O que se deve colocar nos ficheiros fonte e nos ficheiros de interface respectivos.
    6. A excepção das funções e procedimentos inline.
  3. Noções sobre espaços nominativos (namespaces).  Modularização em pacotes.
    1. Utilidade.
    2. Sintaxe.
    3. Utilização: directivas e declarações.
Guião

Desenhar no quadro o código abaixo.  Dizer para não passarem!

No primeiro semestre fomos sempre desenvolvendo programas que consistiam num único ficheiro C++.  Hoje vamos ver que é possível um programa consistir em vários ficheiros.

A divisão de um programa em vários ficheiros permite aquilo a que se chama "compilação separada".  Tem várias vantagens face à solução de um ficheiro único:

  1. É mais fácil dividir o trabalho, pois cada equipa a trabalhar num projecto pode dedicar-se a desenvolver um ou mais ficheiros.  Por exemplo, se for um projecto de gestão, uma equipa pode desenvolver separadamente as ferramentas relacionadas com a logística e outra equipa as ferramentas relacionadas com a contabilidade.
  2. Permite uma divisão lógica do programa de acordo com o objectivo de cada ferramenta.  Pode haver um conjunto de ficheiros relacionado com a logística e outro relacionado com a contabilidade.
  3. A construção do programa (o chamado ficheiro executável) pode ser acelerada, pois uma pequena alteração num ficheiro deixa de exigir a recompilação de todo o projecto!
  4. Facilita a reutilização de código: podem-se reaproveitar com facilidade as ferramentas de contabilidade deste projecto noutro projecto qualquer simplesmente copiando os ficheiros respectivo.
A divisão dos programas em vários ficheiros é uma forma de modularização.  No semestre passado viram-se duas formas de modularizar em C++, embora só uma delas tenha sido apresentada formalmente como tal:
  1. Ao nível mais baixo (atómico), as funções e procedimentos constituem módulos que modularizam algoritmos:
  2. A um nível mais elevado, as classes modularizam dados e respectivas operações:
Existem dois níveis de modularização para além destes: o nível da modularização física e o nível dos pacotes.

A modularização física corresponde à divisão de um programa em ficheiros.  Infelizmente, os módulos deste nível de modularização são conhecidos por módulos simplesmente, o que se pode tornar um pouco confuso.  Dependendo do contexto, a palavra "módulo" pode ter o seu sentido lato (um módulo qualquer, que pode ser uma função, uma classe, um módulo físico, ou um pacote), ou o seu sentido estrito de um módulo físico.

Os módulos físicos correspondem tipicamente a um par de ficheiros fonte: o ficheiro de implementação (com extensão .C) e o ficheiro de interface ou cabeçalho (de extensão .H).  No primeiro indica-se como se usam as ferramentas disponibilizadas pelo módulo e no segundo implementam-se as ditas ferramentas.  Quem usa o módulo normalmente limita-se a usar o ficheiro de interface.

Finalmente, os pacotes são tipicamente constituídos por vários módulos físicos, com ferramentas relacionadas.  Em C++ os pacotes não são suportados directamente.  Mas existe uma construção sintáctica no C++ que permite organizar as ferramentas quase como num pacote: os espaços nominativos.

Quer a modularização física quer os espaços nominativos são assunto desta aula.  Antes de avançarmos, porém, vamos estudar as várias fases da construção de um programa (um ficheiro executável) a partir de vários módulos.

programa.C:

#include "matematica.H"

#define TURMAS_GRANDES

#ifdef TURMAS_GRANDES
int const número_máximo_de_alunos = 100;
#else
int const número_máximo_de_alunos = 30;
#endif

int main()
{
    int notas[número_máximo_de_alunos];
    ....
    cout << média(notas, 10) << endl;
}

matematica.H:

double média(int const m[], int n);

matematica.C:

#include "matematica.H"

double média(int const m[], const int n) 
{

    double soma = 0.0;
    for(int i = 0; i != n; ++i)
        soma += m[i];
    return soma / n;
}

 Dizer que o ficheiro programa.C está gatado, pois não tem #include <iostream>.  Pedir-lhes para imaginarem que se podia usar o cout de qualquer forma...

A construção de uma ficheiro executável a partir de um conjunto de ficheiros fonte passa para 3 fases:

  1. Pré-processamento: pré-processador
  2. Compilação: compilador
  3. Fusão (to link): fusor (linker)
Note-se que "compilação" é apenas uma das fases da construção!  É típico usar o termo "compilação" para o processo completo, e temo-lo feito até agora, mas em rigor é incorrecto.

O pré-processamento pega num ficheiro fonte, tipicamente num ficheiro de implementação .C, e gera ficheiro pré-processado, com extensão .ii.  O ficheiro pré-processado continua a conter código C++, mas o pré-processador faz-lhe algumas alterações.  O comando para pré-processar é:

c++ -E programa.C -o programa.ii
c++ -E matematica.C -o matematica.ii
O compilador pega num ficheiro pré-processado e tradu-lo para linguagem máquina.  Mas não gera um ficheiro executável!  Gera um ficheiro objecto, que além de conter o código máquina contém informação adicional acerca desse código máquina.  Os ficheiros objecto têm extensão .o.  O comando para compilar é:
c++ -Wall -g -c programa.ii
c++ -Wall -g -c matematica.ii
c++ -Wall -g -c programa.C
c++ -Wall -g -c matematica.C
Explicar que na segunda versão a compilação é precedida pelo pré-processamento).

Note-se que quer o pré-processamento quer a compilação agem SOBRE UM SÓ FICHEIRO!  Daí que se fale em compilação separada: os ficheiros .C são todos compilados separadamente...

O fusor é que funde todos os ficheiros objecto do programa e gera o ficheiro executável.

g++ -o programa programa.o matematica.o
Vamos ver cada uma destas fases mais em pormenor:

PRÉ-PROCESSAMENTO

Explicar que o pré-processor copia do ficheiro .C para o ficheiro .i, mas sempre que encontra uma linha começada por # interpreta-a.  São as chamadas directivas de pré-processamento.

Dizer que o pré-processador é uma ferramenta pré-histórica que se mantém no C++ por causa das suas origens no C.

Dizer há muitas directivas do pré-processador, mas só algumas nos interessam.

Explicar #include.  Explicar diferença entre <> (ficheiro de interface "oficial") e "" (ficheiro de interface "nosso").  Dizer que includes podem ter includes.

Explicar vagamente macros como "variáveis" do pré-processador.

Explicar compilação condicional.  Dizer que nas aulas práticas se verá uma melhor aplicação para ela.

COMPILAÇÃO

A compilação de um ficheiro C++ (já pré-processado) consiste na sua tradução para linguagem máquina e é feita em três passos pelo menos:

  1. Análise lexical
  2. Análise sintáctica
  3. Análise semântica
Explicar cada passo fazendo analogia com o português.  Lexical: divisão da sequência de caracteres em palavras e símbolos.  Sintáctica: verificação das regras de gramática da linguagem.  Semântica: "a alface chocou um ovo"...

O resultado da compilação não é apenas código máquina.  É algo mais.  Simplificando grosseiramente as coisas, tem associadas duas tabelas: uma das disponibilidades, e outra das necessidades.

Explicar que o ficheiro media.o tem na tabela das disponibilidades a função main e na das necessidades a função média.  Por outro lado o ficheiro matematica.o tem na tabela das disponibilidades a função media e na tabela das necessidades não tem nada.

Cada ficheiro objecto, por si só, não é executável.  Por exemplo, ao ficheiro matematica.o falta a função main() e ao ficheiro media.o falta a função media().  A informação do que cada ficheiro objecto tem e do que necessita é o que possibilita ao fusor fazer o seu trabalho.

FUSÃO

Na fase da fusão os ficheiro objecto são todos fundidos num único executável.

Fazer boneco mostrando as três fase que evidencie bem as compilações separadas e a fusão final.

O fusor basicamente concatena o código máquina de cada ficheiro objecto depois de verificar que:

  1. Não há repetições: não pode haver dois ficheiros objecto a definir a mesma coisa.  Se houver repetições o fusor dá erro!
  2. Há uma função main(), pois de outra forma não se poderia criar o executável.
  3. Para cada necessidade de cada ficheiro objecto existe uma disponibilidade correspondente num outro ficheiro objecto: o que um precisa tem de haver outro que tem.  Se faltar alguma coisa o fusor dá erro!
Mencionar brevemente a fusão dinâmica (as Dynamic Link Libraries do Windows).

Dizer que mesmo no semestre passado não trabalhavam só com um ficheiro!

O programa mais simples tem pelo menos um #include e precisa de ferramentas da chamada biblioteca padrão do C++.  O que acontece é que o fusor funde os nosso ficheiros objecto automaticamente com a biblioteca.  Mais, nós podemos criar as nossas próprias bibliotecas!  Podemos pegar num conjunto de ficheiros objecto e arquivá-los num ficheiro de arquivo ou biblioteca: prefixo lib e extensão .a.

Para arquivar ficheiros objecto não se usa o fusor: usa-se o arquivador.  O programa arquivador invoca-se normalmente como se segue:

ar ru ficheiros.o ...
Já vimos como era feita a compilação separada.  Vamos voltar à modularização física.  Um módulo físico corresponde tipicamente a dois ficheiros fonte: o ficheiro de implementação e o de interface.  É normal que o módulo onde está o main() não tenha ficheiro de interface.  Assim, no nosso exemplo temos dois módulos:
matematica: ficheiros fonte matematica.C e matematica.H
programa: ficheiro fonte programa.C
Que colocar em cada módulo?  O que se coloca em cada um dos ficheiro fonte?

A primeira pergunta é mais difícil de responder duma forma taxativa.  Mas pode-se dizer que cada módulo deve corresponder a um conjunto muito coeso de funções, procedimentos, classes, etc.  É típico que um módulo corresponda a uma classe apenas e algumas funções e procedimentos membro associadas a essa classe.  Por vezes há mais do que uma classe, quando essas classes estão muito interligadas.  Por vezes não há classe nenhuma, como no nosso módulo matemática, que provavelmente só conteria funções e procedimentos para operações matemáticas.

A outra pergunta é mais fácil de responder.  Em cada módulo, o que se coloca no ficheiro de interface e o que se coloca no ficheiro de implementação?  Ao ficheiro de interface só deve ir parar aquilo que é estritamente necessário para se poder usar o módulo.  Em particular:

  1. Definições de classes (que contêm a declaração das funções e procedimentos membro).
  2. Declarações de funções e procedimentos não membro e não inline.
  3. Definições de funções e procedimentos membro ou não membro inline.
  4. Definições de constantes.
  5. Declarações de variáveis globais (EVITEM-NAS).
Ao ficheiro de implementação vai parar o restante:
  1. Definições de tudo o que for apenas útil dentro do módulo, que pode incluir classes, funções, procedimentos, etc.
  2. Definições de todas as funções e procedimentos membro ou não membro e que não sejam inline.
  3. Definições de todas as variáveis globais.
Repare-se que, mais uma vez, os módulos são caixas pretas (que ocultam uma implementação) com uma interface bem definida (pelo ficheiro de interface).

E falta falar do nível de modularização seguinte: os pacotes.

Explicar problema da colisão de nomes.  Dar exemplo com empresa que compra duas bibliotecas de funções, uma para lidar com a logística e outra com a contabilidade e que quer usá-las simultaneamente num programa.

Manda construir o programa e... o fusor queixa-se que cada uma das bibliotecas define um procedimento consolida().  Que fazer?  Pedir aos fornecedores para alterarem o nome do procedimento?  E eles mudam?  E quanto tempo levam?

O C++ tem um mecanismo que evita este problema da colisão de erros e que, simultaneamente, pode ser usado para agrupar as ferramentas num nível mais elevado de modularização: os espaços nominativos (namespaces).

Podem-se colocar vários módulos num mesmo espaço nominativo (e vice-versa, mas não é tão útil...).  Por exemplo, se as empresas fornecedores (Verão Software e Inverno Software) tivem sido mais competentes, todos os módulos das suas bibliotecas estariam num espaço nominativo correspondente ao nome da empresa.  Por exemplo:

logistica.H

namespace VerãoSoft {
    ...
    void consolida();
    ...
}
logistica.C
...
void VerãoSoft::consolida() {
    ...
}
...


contabilidade.H

namespace InvernoSoft {
    ...
    void consolida();
    ...
}
contabilidade.C
...
void InvernoSoft::consolida() {
    ...
}
...
Agora a nossa empresa não tem problemas.  Pode escrever:
#include <contabilidade.H>
#include <logistica.H>

int main()
{
    VerãoSoft::consolida();
    InvernoSoft::consolida();
}

Explicar directiva de utilização para passar todos os nomes para o âmbito de visibilidade corrente.  Explicar versão menos dramática da declaração de utilização.  Relembrar std::.  Explicar que o problema em Linux (pr enquanto) é que a biblioteca não está em std:: mas deveria estar...

Cada uma dessas empresas podia ter na biblioteca vários pacotes (cada um com vários módulos físicos), com objectivos diferentes.  Nesse caso cada um teria espaços nominativos diferentes:

logistica_básica.H

namespace VerãoSoft {
    namespace Logística {
        ...
        void consolida();
        ...
    }
}
logistica_básica.C
...
void VerãoSoft::Logística::consolida() {
    ...
}
...


contabilidade_avançada.H

namespace InvernoSoft {
    namespace Contabilidade {
        ...
        void consolida();
        ...
    }
}
contabilidade_avançada.C
...
void InvernoSoft::Contabilidade::consolida() {
    ...
}
...
Agora a nossa empresa deve escrever:
#include <contabilidade_avançada.H>
#include <logistica_básica.H>

using namespace VerãoSoft;
using namespace InvernoSoft;

int main()
{
    Logística::consolida();
    Contabilidade::consolida();
}