Aula 2

Compilação

  • Da última vez, aprendemos a escrever nosso primeiro programa em C. Aprendemos a sintaxe para a função main em nosso programa, a função printf para imprimir no terminal, como criar strings com aspas duplas e como incluir stdio.h para a função printf.
  • Em seguida, compilamos com clang hello.c para poder executar ./a.out (o nome padrão) e clang -o hello hello.c (passando um argumento da linha de comando para o nome da saída) para poder executar ./hello.
  • Se quiséssemos usar a biblioteca do CS50, via #include <cs50.h>, para strings e a função get_string, também temos que adicionar um sinalizador: clang -o hello hello.c -lcs50. O sinalizador -l vincula o arquivo cs50, que já está instalado no CS50 Sandbox, e inclui protótipos, ou definições de strings e get_string (entre mais) que nosso programa pode então fazer referência e usar.
  • Escrevemos nosso código-fonte em C, mas precisamos compilá-lo em código de máquina, em binário, antes que nossos computadores possam executá-lo.
  • clang é o compilador, e make é um utilitário que nos ajuda a executar o clang sem ter que indicar todas as opções manualmente.
  • "Compilar" o código-fonte em código de máquina é, na verdade, composto de etapas menores:
    • pré-processamento
    • compilação
    • montagem
    • linkagem
  • Pré-processamento envolve olhar para as linhas que começam com um #, como #include, antes de tudo. Por exemplo, #include <cs50.h> dirá ao clang que procure primeiro por esse arquivo de cabeçalho, pois ele contém conteúdo que queremos incluir em nosso programa. Então, clang essencialmente substituirá o conteúdo desses arquivos de cabeçalho em nosso programa.
  • Por exemplo …

      #include <cs50.h>
      #include <stdio.h>
    
      int main(void)
      {
          string name = get_string("Name: ");
          printf("hello, %s\n", name);
      }
    
  • … será pré-processado em:

      string get_string(string prompt);
      int printf(const char *format, ...);
    
      int main(void)
      {
          string name = get_string("Name: ");
          printf("hello, %s\n", name);
      }
    
  • Compilação pega nosso código-fonte, em C, e o converte em código assembly, que se parece com isto:

      ...
      main:                         # @main
          .cfi_startproc
      # BB#0:
          pushq    %rbp
      .Ltmp0:
          .cfi_def_cfa_offset 16
      .Ltmp1:
          .cfi_offset %rbp, -16
          movq    %rsp, %rbp
      .Ltmp2:
          .cfi_def_cfa_register %rbp
          subq    $16, %rsp
          xorl    %eax, %eax
          movl    %eax, %edi
          movabsq    $.L.str, %rsi
          movb    $0, %al
          callq    get_string
          movabsq    $.L.str.1, %rdi
          movq    %rax, -8(%rbp)
          movq    -8(%rbp), %rsi
          movb    $0, %al
          callq    printf
          ...
    
  • Essas instruções são de nível inferior e estão mais próximas das instruções binárias que o CPU de um computador pode entender diretamente. Elas geralmente operam em bytes, ao contrário de abstrações como nomes de variáveis.

  • O próximo passo é pegar o código assembly e traduzi-lo em instruções em binário por montagem. As instruções em binário são chamadas de código de máquina, que a CPU de um computador pode executar diretamente.

  • A última etapa é vinculação, onde o conteúdo de bibliotecas anteriormente compiladas que queremos vincular, como cs50.c, são realmente combinadas com o binário de nosso programa. Então, acabamos com um arquivo binário, a.out ou hello, que é a versão compilada de hello.c, cs50.c e printf.c.

Depuração

  • Bugs são erros em programas que não pretendíamos cometer. E a depuração é o processo de encontrar e corrigir bugs.

help50 e printf

  • Digamos que escrevemos este programa, buggy0.c:

      int main(void)
      {
          printf("hello, world\n");
      }
    
    • Vemos um erro (em vermelho), quando tentamos make este programa, que estamos declarando implicitamente a função de biblioteca 'printf'. Não entendemos muito isso, então podemos executar help50 make buggy0, que nos dirá, no final, que podemos ter esquecido de escrever #include <stdio.h>, que contém printf.
  • Podemos tentar novamente com buggy1.c:

      #include <stdio.h>
    
      int main(void)
      {
          string name = get_string("What's your name?\n");
          printf("hello, %s\n", name);
      }
    
    • Vemos muitos erros, e mesmo o primeiro não parece fazer muito sentido. Então, podemos executar novamente help50 make buggy1, que irá nos sugerir que precisamos de cs50.h pois string não está definido.
  • Para limpar a janela do terminal (para que só possamos ver a saída do que quisermos executar em seguida), podemos pressionar control + L, ou digitar clear como um comando na janela do terminal.

  • Vamos dar uma olhada em buggy2.c:

      #include <stdio.h>
    
      int main(void)
      {
          for (int i = 0; i <= 10; i++)
          {
              printf("#\n");
          }
      }
    
    • Hmm, pretendíamos ver apenas 10 #, mas há 11. Se não soubéssemos qual é o problema (pois nosso programa está compilando sem nenhum erro, e agora temos um erro lógico), poderíamos adicionar outra linha de impressão para nos ajudar:

        #include <stdio.h>
      
        int main(void)
        {
            for (int i = 0; i <= 10; i++)
            {
                printf("i agora é %i: ", i);
                printf("#\n");
            }
        }
      
    • Agora, vemos que i começou em 0 e continuou até chegar a 10, mas deveríamos interromper quando chegasse a 10, com i < 10 em vez de i <= 10.

debug50

  • Hoje também daremos uma olhada no IDE do CS50, que é como a Sandbox do CS50, mas com mais recursos. É um ambiente de desenvolvimento online, com um editor de código e uma janela de terminal, além de ferramentas para depuração e colaboração: Janela do navegador com IDE do CS50, editor de código na parte superior com buggy2.c, janela do terminal na parte inferior
  • No IDE do CS50, teremos outra ferramenta, debug50, para nos ajudar a depurar programas.
  • Abriremos buggy2.c e tentaremos make buggy2. Mas salvamos buggy2.c em uma pasta chamada src2, então precisamos executar cd src2 para alterar nosso diretório para o correto. E o terminal do IDE do CS50 irá nos lembrar em que diretório estamos, com um prompt como ~/src/ $. (O ~ indica o padrão ou diretório inicial).
  • Em vez de usar printf, também podemos depurar nosso programa interativamente. Podemos adicionar um ponto de interrupção, ou um indicador para uma linha de código em que o depurador deve pausar nosso programa. Por exemplo, podemos clicar à esquerda da linha 5 de nosso código, e um círculo vermelho aparecerá: Editor de código com ícone vermelho ao lado da linha 5 de código
  • Agora, se executarmos debug50 ./buggy2, veremos o painel do depurador aberto à direita: Painel do depurador com controles, variáveis
  • Vemos que a variável que criamos, i, está na seção Variáveis Locais e vemos que há um valor de 0.
  • Nosso ponto de interrupção pausou nosso programa após a linha 5, logo antes da linha 7, pois é a primeira linha de código que pode ser executada. Para continuar, temos alguns controles no painel do depurador. O triângulo azul continuará nosso programa até atingirmos outro ponto de interrupção ou o final de nosso programa. A seta curva à sua direita irá "passar por cima" da linha, executando-a e pausar nosso programa novamente imediatamente depois.
  • Então, usaremos a seta curva para executar a próxima linha e ver o que muda depois. Estamos na linha printf e pressionando a seta curva novamente, vemos um único # impresso em nossa janela de terminal. Com outro clique da seta, vemos que o valor da i à direita muda para 1. E podemos continuar clicando na seta para observar a execução do nosso programa, uma linha de cada vez.
  • Para sair do depurador, podemos pressionar control + C para interromper o programa.
  • Podemos economizar muito tempo no futuro investindo um pouco agora para aprender a usar o debug50!

check50 e style50

  • Podemos executar um comando como check50 cs50/problems/hello, onde check50 é um programa que seguirá as instruções identificadas pelo argumento cs50/problems/hello para carregar, executar e testar seu programa em servidores CS50. Isto checará a correção de seu programa.
    • Ao escrever software no mundo real, os desenvolvedores geralmente escreverão seus próprios testes para garantir que seu código funciona como esperado, especialmente quando mais funcionalidades são adicionadas ao mesmo código.
  • style50 é outro programa que checará nosso código em busca de questões estéticas, como espaço em branco, de forma que nosso código seja mais legível e fácil de manter. Por exemplo, podemos estar perdendo recuo. E o Guia de Estilo incluirá mais explicações sobre o que esperamos.
  • Podemos até usar a depuração de borracha de pato, um método no qual explicamos o que estamos tentando fazer para um pato de borracha, de forma que percebamos o que estamos tentando fazer e o que devemos corrigir.
  • Também queremos escrever nosso código com bom design, onde não apenas resolvemos o problema corretamente, mas bem, onde fazemos escolhas razoáveis de como nosso programa é executado, e fazemos compensações entre tempo, custo de desenvolvimento e memória.

Tipos de Dados

  • Em C, temos diferentes tipos de variáveis que podemos usar para armazenar dados:
    • bool 1 byte
    • char 1 byte
    • int 4 bytes
    • float 4 bytes
    • long 8 bytes
    • double 8 bytes
    • string ? bytes
  • Cada um destes tipos ocupa um determinado número de bytes por variável que criamos, e os tamanhos acima são o que a caixa de areia, IDE, e mais provavelmente seu computador usam para cada tipo em C.

Memória

  • Dentro de nossos computadores, nós temos chips chamados RAM, memória de acesso aleatório, que armazena dados para uso a curto prazo. Nós podemos salvar um programa ou arquivo em nosso disco rígido (ou SSD) para armazenamento a longo prazo, mas quando nós o abrimos, ele é primeiramente copiado para a RAM. Embora a RAM seja muito menor e temporária (até que a energia seja desligada), ela é muito mais rápida.
  • Nós podemos pensar em bytes, armazenados na RAM, como se eles estivessem em uma grade:
    chip do computador com grade sobreposta
    • Na realidade, há milhões ou bilhões de bytes por chip.
  • Em C, quando nós criamos uma variável do tipo char, que terá o tamanho de um byte, ela será fisicamente armazenada em uma daquelas caixinhas na RAM. Um inteiro, com 4 bytes, ocupará quatro daquelas caixinhas.
  • E cada uma dessas caixinhas é rotulada com algum número, ou endereço, de 0 a 1, a 2 e assim por diante.

Arrays

  • Vamos dizer que queremos armazenar três variáveis:

      #include <stdio.h>
    
      int main(void)
      {
          char c1 = 'H';
          char c2 = 'I';
          char c3 = '!';
          printf("%c %c %c\n", c1, c2, c3);
      }
    
    • Observe que usamos aspas simples para indicar um caractere literal e aspas duplas para vários caracteres juntos em uma string.
    • Podemos compilar e executar isso para ver H I !.
  • E sabemos que os caracteres são apenas números, portanto, se alterarmos nossa formatação de string para printf("%i %i %i\n", c1, c2, c3);, podemos ver os valores numéricos de cada char impresso: 72 73 33.

    • Podemos converter ou lançar explicitamente cada caractere em um int antes de usá-lo com (int) c1, mas nosso compilador pode fazer isso implicitamente para nós.
  • E na memória, podemos ter três caixas rotuladas como c1, c2 e c3, cada uma representando um byte de binário com os valores de cada variável.
  • Vamos dar uma olhada em scores0.c:

      #include <cs50.h>
      #include <stdio.h>
    
      int main(void)
      {
          // Pontuações
          int score1 = 72;
          int score2 = 73;
          int score3 = 33;
    
          // Imprimir média
          printf("Média: %i\n", (score1 + score2 + score3) / 3);
      }
    
    • Podemos imprimir a média de três números, mas agora precisamos criar uma variável para cada escore que queremos incluir e não podemos usá-las facilmente mais tarde.
  • Acontece que, na memória, podemos armazenar variáveis umas após as outras, consecutivamente. E em C, uma lista de variáveis armazenadas uma após a outra em um pedaço contíguo de memória é chamada de array.

  • Por exemplo, podemos usar int scores[3]; para declarar um array de 3 inteiros.
  • E podemos atribuir e usar variáveis em um array com:

      #include <cs50.h>
      #include <stdio.h>
    
      int main(void)
      {
          // Pontuações
          int scores[3];
          scores[0] = 72;
          scores[1] = 73;
          scores[2] = 33;
    
          // Imprimir média
          printf("Média: %i\n", (scores[0] + scores[1] + scores[2]) / 3);
      }
    
    • Observe que os arrays são indexados por zero, o que significa que o primeiro elemento, ou valor, tem índice 0.
  • E repetimos o valor 3, representando o comprimento de nosso array, em dois lugares diferentes. Portanto, podemos usar uma constante ou valor fixo para indicar que ele sempre deve ser o mesmo nos dois lugares:

      #include <cs50.h>
      #include <stdio.h>
    
      const int N = 3;
    
      int main(void)
      {
          // Pontuações
          int scores[N];
          scores[0] = 72;
          scores[1] = 73;
          scores[2] = 33;
    
          // Imprimir média
          printf("Média: %i\n", (scores[0] + scores[1] + scores[2]) / N);
      }
    
    • Podemos usar a palavra-chave const para dizer ao compilador que o valor de N nunca deve ser alterado por nosso programa. E por convenção, colocaremos nossa declaração da variável fora da função main e colocaremos seu nome em maiúsculas, o que não é necessário para o compilador, mas mostra a outros humanos que esta variável é uma constante e torna fácil de ver desde o começo.
  • Com um array, podemos coletar nossas pontuações em um loop e acessá-las mais tarde em um loop também:

      #include <cs50.h>
      #include <stdio.h>
    
      float average(int length, int array[]);
    
      int main(void)
      {
          // Obter o número de pontuações
          int n = get_int("Pontuações:  ");
    
          // Obter pontuações
          int scores[n];
          for (int i = 0; i < n; i++)
          {
              scores[i] = get_int("Pontuação %i: ", i + 1);
          }
    
          // Imprimir média
          printf("Média: %.1f\n", average(n, scores));
      }
    
      float average(int length, int array[])
      {
          int sum = 0;
          for (int i = 0; i < length; i++)
          {
              sum += array[i];
          }
          return (float) sum / (float) length;
      }
    
    • Primeiro, pediremos ao usuário o número de pontuações que ele possui, criaremos um array com ints suficientes para o número de pontuações que ele possui e usaremos um loop para coletar todas as pontuações.
    • Em seguida, escreveremos uma função auxiliar, average, para retornar um float ou um valor decimal. Vamos passar o comprimento e um array de ints (que pode ser de qualquer tamanho) e usar outro loop dentro de nossa função auxiliar para adicionar os valores em uma soma. Usamos (float) para converter sum e length em floats, portanto, o resultado que obtemos ao dividir os dois também é um float.
    • Por fim, quando imprimimos o resultado que obtemos, usamos %.1f para mostrar apenas um lugar após a vírgula decimal.
  • Na memória, nosso array é armazenado assim, onde cada valor ocupa não um, mas quatro bytes:
    grade com 72 chamado score1, 73 chamado score2, 33 chamado score3, cada um ocupando quatro caixas e muitas caixas vazias a seguir

Strings

  • Strings são na verdade apenas matrizes de caracteres. Se tivermos uma string s, cada caractere pode ser acessado com s[0], s[1] e assim por diante.
  • E acontece que uma string termina com um caractere especial, '\0', ou um byte com todos os bits definidos como 0. Esse caractere é chamado de caractere nulo ou caractere de terminação nulo. Portanto, precisamos de quatro bytes para armazenar nossa string “HI!”: grade com H rotulado s[0], I rotulado s[1],! rotulado s[2], \0 rotulado s[3], cada um ocupando uma caixa, e muitas caixas vazias seguindo
  • Agora vamos ver como quatro strings em um array podem se parecer:

      nomes de string[4];
      names[0] = "EMMA";
      names[1] = "RODRIGO";
      names[2] = "BRIAN";
      names[3] = "DAVID";
    
      printf("%s\n", nomes[0]);
      printf("%c%c%c%c\n", nomes[0][0], nomes[0][1], nomes[0][2], nomes[0][3]);
    
    • Podemos imprimir o primeiro valor em 'nomes' como uma string, ou podemos obter a primeira string e obter cada caractere individual naquela string usando '[]' novamente. (Podemos pensar nisso como '(names[0])[0]', embora não precisemos dos parênteses.)
    • E embora saibamos que o primeiro nome tinha quatro caracteres, printf provavelmente usou um loop para olhar cada caractere na string, imprimindo-os um de cada vez até chegar ao caractere nulo que marca o final da string. E, de fato, podemos imprimirnames[0][4]como umintcom%i e ver um 0 sendo impresso.
  • Podemos visualizar cada caractere com seu próprio rótulo na memória: grade com E rotulado names[0][0], M rotulado names[0][1] e assim por diante, até names[3][5] com um \0, cada um ocupando uma caixa e caixas vazias seguindo

  • Podemos tentar fazer experiências com string0.c:

      #include <cs50.h>
      #include <stdio.h>
      #include <string.h>
    
      int main(void)
      {
          string s = get_string("Entrada: ");
          printf("Saída: ");
          para (int i = 0; i < strlen(s); i++)
          {
              printf("%c", s[i]);
          }
          printf("\n");
      }
    
    • Podemos usar a condição s[i] != '\0', onde podemos verificar o caractere atual e imprimi-lo apenas se ele não for o caractere nulo.
    • Também podemos usar o comprimento da string, mas primeiro precisamos de uma nova biblioteca, string.h, para strlen, que nos diz o comprimento de uma string.
  • Podemos melhorar o design do nosso programa. string0 foi um pouco ineficiente, pois verificamos o comprimento da string, após cada caractere ser impresso, em nossa condição. Mas como o comprimento da string não muda, podemos verificar o comprimento da string uma vez:

      #include <cs50.h>
      #include <stdio.h>
      #include <string.h>
    
      int main(void)
      {
          string s = get_string("Entrada: ");
          printf("Saída:\n");
          para (int i = 0, n = strlen(s); i < n; i++)
          {
              printf("%c\n", s[i]);
          }
      }
    
    • Agora, no início do nosso loop, inicializamos uma variável i e n e lembre-se do comprimento de nossa string em n. Então, podemos verificar os valores a cada vez, sem ter que realmente calcular o comprimento da string.
    • E precisamos usar um pouco mais de memória para n, mas isso economiza algum tempo ao não ter que verificar o comprimento da string a cada vez.
  • Agora podemos combinar o que vimos, para escrever um programa que pode colocar letras em maiúsculas:

      # include <cs50.h>
      # include <stdio.h>
      # include <string.h>
    
      int main(void)
      {
          string s = get_string("Antes: ");
          printf("Depois:  ");
          for (int i = 0, n = strlen(s); i < n; i++)
          {
              if (s[i] >= 'a' && s[i] <= 'z')
              {
                  printf("%c", s[i] - 32);
              }
              else
              {
                  printf("%c", s[i]);
              }
          }
          printf("\n");
      }
    
    • Primeiro, obtemos uma string s. Então, para cada caractere na string, se for minúscula (seu valor está entre o de a e z), nós a convertemos para maiúscula. De outra forma, nós apenas a imprimimos.
    • Nós podemos converter uma letra minúscula em sua equivalente maiúscula, subtraindo a diferença entre seus valores ASCII. (Nós sabemos que letras minúsculas têm um valor ASCII maior do que letras maiúsculas, e a diferença é convenientemente a mesma entre as mesmas letras, então podemos subtrair essa diferença para obter uma letra maiúscula de uma letra minúscula.)
  • Nós podemos utilizar as páginas do manual, ou manual do programador, para encontrar funções de biblioteca que podemos utilizar para realizar a mesma coisa:

      # include <cs50.h>
      # include <ctype.h>
      # include <stdio.h>
      # include <string.h>
    
      int main(void)
      {
          string s = get_string("Antes: ");
          printf("Depois:  ");
          for (int i = 0, n = strlen(s); i < n; i++)
          {
              printf("%c", toupper(s[i]));
          }
          printf("\n");
      }
    
    • Ao pesquisar as páginas do manual, vemos toupper() é uma função, entre outras, de uma biblioteca chamada ctype, que podemos utilizar.

Argumentos de linha de comando

  • Usamos programas como make e clang, que aceitam palavras extras após seu nome na linha de comando. Acontece que programas próprios também podem aceitar argumentos de linha de comando.
  • Em argv.c, mudamos como nossa função main se parece:

      #include <cs50.h>
      #include <stdio.h>
    
      int main(int argc, string argv[])
      {
          if (argc == 2)
          {
              printf("hello, %s\n", argv[1]);
          }
          else
          {
              printf("hello, world\n");
          }
      }
    
    • argc e argv são duas variáveis que nossa função main agora receberá quando nosso programa for executado a partir da linha de comando. argc é a contagem de argumentos, ou o número de argumentos, e argv é um array de strings que são os argumentos. E o primeiro argumento, argv[0], é o nome do nosso programa (a primeira palavra digitada, como ./hello). Neste exemplo, verificamos se temos dois argumentos e imprimimos o segundo se for o caso.
    • Por exemplo, se executarmos ./argv David, veremos hello, David impresso, já que digitamos David como a segunda palavra em nosso comando.
  • Acontece que podemos indicar erros em nosso programa retornando um valor da nossa função main (como implicado pelo int antes da nossa função main). Por padrão, nossa função main retorna 0 para indicar que nada deu errado, mas podemos escrever um programa para retornar um valor diferente:

      #include <cs50.h>
      #include <stdio.h>
    
      int main(int argc, string argv[])
      {
          if (argc != 2)
          {
              printf("missing command-line argument\n");
              return 1;
          }
          printf("hello, %s\n", argv[1]);
          return 0;
      }
    
    • O valor de retorno de main em nosso programa é chamado de código de saída.
  • À medida que escrevemos programas mais complexos, códigos de erro como este podem nos ajudar a determinar o que deu errado, mesmo que não seja visível ou significativo para o usuário.

Legibilidade

  • Agora que sabemos como trabalhar com strings em nossos programas, podemos analisar parágrafos de texto quanto ao seu nível de legibilidade, com base em fatores como quão longas e complicadas as palavras e as frases são.

Criptografia

  • Se quiséssemos enviar uma mensagem a alguém, podemos querer criptografar, ou seja, embaralhar essa mensagem de alguma forma para que fosse difícil para outras pessoas lerem. A mensagem original, ou entrada do nosso algoritmo, é chamada de texto não criptografado e a mensagem criptografada, ou saída, é chamada de texto criptografado.
  • Uma mensagem como OI! poderia ser convertida em ASCII, 72 73 33. Mas qualquer pessoa poderia converter isso de volta em letras.
  • Um algoritmo de criptografia geralmente requer outra entrada, além do texto não criptografado. É necessário uma chave e, às vezes, é simplesmente um número mantido em segredo. Com a chave, o texto não criptografado pode ser convertido, por meio de algum algoritmo, em texto criptografado e vice-versa.
  • Por exemplo, se quiséssemos enviar uma mensagem como EU AMO VOCÊ, poderíamos primeiro convertê-la em ASCII: 73 76 79 86 69 89 79 85. Em seguida, podemos criptografá-la com uma chave de apenas 1 e um algoritmo simples, no qual apenas adicionamos a chave a cada valor: 74 77 80 87 70 90 80 86. Então, alguém que converta esse ASCII de volta em texto verá JM PWF ZPV. Para descriptografar isso, alguém precisará saber a chave.
  • Vamos aplicar esses conceitos em nosso conjunto de problemas!