Guião da 1ª Aula Teórica

Sumário

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 código do projecto, bastando recompilar o código afectado pela mudança.
  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 respectivos.
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++:
  1. Ao nível mais baixo (atómico), as rotinas constituem módulos que modularizam algoritmos:
  2. A um nível mais elevado, as classes C++ 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 rotina, uma classe, um módulo físico ou um pacote) ou o seu sentido estrito de módulo físico.

Os módulos físicos correspondem tipicamente a um par de ficheiros ditos 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 e o que fazem as ferramentas disponibilizadas pelo módulo, i.e., o seu contrato, e no segundo implementam-se as ditas ferramentas.  Quem consome o módulo normalmente apenas usa directamente 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 const n);

matematica.C:

#include "matematica.H"

double média(int const m[], int const 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...

Dizer ainda que faltam instruções de asserção, etc.  A ideia é reduzir o código ao mínimo essencial para mostrar o que se pretende.

A construção de um 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 ou ligação (to link): fusor ou ligador (linker)
Explicar o que são os ficheiros fonte de novo!

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.  A um ficheiro de implementação pré-processado chama-se uma unidade de tradução.  A unidade de compilação 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 numa unidade tradução e tradu-la 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++ [opções] -c programa.ii
c++ [opções] -c matematica.ii

c++ [opções] -c programa.C
c++ [opções] -c matematica.C
Explicar que na segunda versão a compilação é precedida pelo pré-processamento.

Explicar opções de compilação: -Wall -g -ansi -pedantic

Note-se que a compilação age sobre um único ficheiro!  Daí que se fale em compilação separada: os ficheiros de implementação, .C, são pré-processados e depois são compilados separadamente...

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

c++ -o programa programa.o matematica.o

Vamos ver cada uma destas fases mais em pormenor:

Pré-processamento

O pré-processor copia do ficheiro de implementação .C para a unidade de tradução .ii, mas sempre que encontra uma linha começada por # interpreta-a: essas linhas contêm as chamadas directivas de pré-processamento.

O pré-processador é uma ferramenta pré-histórica que se mantém no C++ por causa das suas origens no C...  Há muitas directivas de pré-processamento, mas só algumas nos interessam.

Explicar #include.  Explicar diferença entre <> (ficheiro de interface "oficial") e "" (ficheiro de interface "nosso").  Dizer que um ficheiro depois de incluído é também pré-processado, e por isso pode ter outras inclusões.

Explicar vagamente macros como "constantes" 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, ou seja, a compilação de uma unidade de tradução, consiste na sua tradução para linguagem máquina.  É feita em pelo menos três passos:

  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: "A a alfac um choou ovo".  Sintáctica: verificação das regras de gramática da linguagem: "A a alface um chocou ovo".  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, o ficheiro objecto resultante da compilação contém duas tabelas: uma das disponibilidades, e outra das necessidades.

Explicar que o ficheiro programa.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 programa.o falta a função média().  A informação do que cada ficheiro objecto tem e do que necessita é o que possibilita ao fusor fazer o seu trabalho.

Fusão ou ligação

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

Fazer boneco mostrando as três fases 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 e as Shared Objects em Linux).

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 nossos 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 libarquivo.a ficheiro_objecto.o ...

Explicar vagamente r e uReplace e update.

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á a função main() não tenha ficheiro de interface.  Assim, no nosso exemplo temos dois módulos físicos:

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 rotinas, classes, etc.  É típico que um módulo corresponda a uma classe apenas e algumas rotinas 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 rotinas 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 respectiva operações).
  2. Declarações de rotinas não membro e não inline.
  3. Definições de rotinas e de métodos 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 necessário dentro do módulo, que pode incluir classes, rotinas, etc.
  2. Definições de todas as rotinas e métodos que não sejam inline.
  3. Definições de todas as variáveis globais.
Talvez seja preferível apresentar as regras acima em duas fases: primeiro o caso normal, depois as rotinas em-linha.  Nessa altura pode-se explicar convenção do ficheiro auxiliar de implementação _impl.H.

Repare-se que, mais uma vez, os módulos são caixas pretas (que ocultam uma implementação no ficheiro de implementação) com uma interface bem definida (no 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.

Verão Software:

logistica.H

...

void consolida();

...

logistica.C

...

void consolida() 
{

    ...
}

...

liblogistica.a:

logistica.o

Inverno Software:

contabilidade.H

...

void consolida();


...

contabilidade.C

...

void consolida()
{
   
...
}

...

libcontabilidade.a:

contabilidade.o

Explicar que os ficheiros de implementação não são fornecidos!  Estão no segredo dos deuses.

O nosso programa é:

programa.C:

#include <contabilidade.H>
#include <logistica.H>

int main()
{
    consolida();
}

Qual das versões do procedimento é invocada?

Explicar o que acontece quando se usa:

c++ -o programa programa.C -llogistica -lcontabilidade

A compilação tem sucesso, pois a forma de fundir os arquivos pára a pesquisa quando encontra o procedimento, não pesquisando os seguintes!

Se se tivesse acesso aos ficheiros objecto:

c++ -o programa programa.C logistica.o contabilidade.o

a fusão daria um erro por definição duplicada!

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 (name spaces).

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

Verão Software:

logistica.H

namespace VerãoSoftware {
    ...

    void consolida();

    ...
}

logistica.C

...

void VerãoSoftware::consolida() 
{

    ...
}

...

Inverno Software:

contabilidade.H

namespace InvernoSoftware {
    ...

    void consolida();

    ...
}

contabilidade.C

...

void InvernoSoftware::consolida()

{
   
...
}

...

Agora a nossa empresa não tem problemas.  Pode escrever:

#include <contabilidade.H>
#include <logistica.H>

int main()
{
    VerãoSoftware::consolida();
    InvernoSoftware::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::.  Dizer que o registo de sociedades garante que não há duplicação de nomes a nível nacional.  Mas a internacional...  Pode-se usar outro espaço nominativo pt, por exemplo, que é o símbolo internacional de Portugal.

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:

Verão Software:

logistica.H

namespace VerãoSoftware {
    namespace Logística {
        ...

        void consolida();

        ...
    }
}

logistica.C

...

void VerãoSoftware::Logística::consolida() 
{

    ...
}

...

Inverno Software:

contabilidade.H

namespace InvernoSoftware {
    namespace Contabilidade {
        ...

        void consolida();

        ...
    }
}

contabilidade.C

...

void InvernoSoft::Contabilidade::consolida()

{
   
...
}

...

Agora a nossa empresa pode escrever:

#include <contabilidade.H>
#include <logistica.H>

using namespace VerãoSoftware;
using namespace InvernoSoftware;

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

Dizer que os ficheiros de interface também podem ter nomes coincidentes.  Por isso deve-se adoptar a estratégia de os colocar em directórios correspondentes ao espaço nominativo a que pertencem.  Por exemplo pt/VerãoSoftware/Logística/logistica.H.