diagrama

Iniciemos nossa jornada juntos. Antes de mais nada, assegure-se de que o ambiente OCaml esteja devidamente instalado em sua máquina. Caso ainda esteja no início, sem OCaml instalado, orientações detalhadas aguardam por você no site oficial do OCaml.

Preparando o Terreno

Com o ambiente preparado, abra o terminal e execute o comando dune init proj blog no terminal. A execução desse comando engendra a criação um diretório denominado blog com a seguinte estrutura:

blog/
├── dune-project
├── test
│   ├── dune
│   └── blog.ml
├── lib
│   └── dune
├── bin
│   ├── dune
│   └── main.ml
└── project_name.opam

Primeiros Passos com o Código

Com a estrutura do projeto estabelecida, proceda substituindo o conteúdo do arquivo blog/bin/main.ml pelo código a seguir:

blog/bin/main.ml
let get_dir path =
  let dir = Filename.get_temp_dir_name() ^ "/" ^ path in
  if not (Sys.file_exists dir) then
    Unix.mkdir dir 0o775; (* Sets folder permissions to rwxrwxr-x *)
  dir
;;

Interagindo com o OCaml

O passo subsequente envolve a execução do ambiente interativo utop1 através do terminal. Após ativar utop, carregue o conteúdo do arquivo main.ml com o comando:

# #use "bin/main.ml";;

Ao fazer isso, o conteúdo do arquivo é carregado no utop. Execute a função get_dir passando o argumento "greetings" para ela, o resultado varia baseado no sistema; no meu caso, o resultado foi /tmp/greetings. Tome nota, esse diretório é onde armazenaremos arquivos ao longo desta série.

Explorando o Conceito de Canais

Vamos começar com alguma perspectiva sobre o que é um processo2 no mundo. Ao simplesmente observar um computador executando, podemos observar uma infinidade de processos sendo executados, processos correspondem a coisas sendo executadas, como por exemplo, o que você vê agora na tela de seu computador enquanto lê esse texto, é um processo, o navegador que você está utilizando para ler esse texto é um processo, o sistema operacional que está executando o navegador é um processo, e assim por diante. Para o propósito deste texto, vou usar uma simplificação, que eu li no livro, socket and pipes, que é a seguinte: "Um processo é uma instância de um programa em execução". Mas como dito no livro, a distinção não é tão clara, como por exemplo, uma instância única de um web browser pode criar um processo separado para cada aba.

Podemos criar quantos processos quisermos, mas enquanto essa multiplicidade existe em software, o hardware possui recursos finitos. Uma máquina pode ter somente uma tela para exibir os gráficos, uma caixa de som para tocar o som, um chip para armazenar todos os arquivos, um cabo ou wireless para transmitir dados ligando a máquina à internet. É surpreendente que um computador possa trabalhar, já imaginou o mesmo cenário em alguma atividade do seu dia a dia, imagine cozinhar simultaneamente com diversas pessoas na mesma cozinha e compartilhar a mesma frigideira enquanto todos tentam fritar um ovo, ou tentar assistir um filme com diversas pessoas na mesma sala, todos tentando assistir a um filme diferente. A habilidade de permitir que múltiplos atores coexistam harmoniosamente, compartilhando os mesmos recursos sem prejuízo às suas atividades individuais, é conhecida pelo termo técnico multiplexing. Este é o trabalho do sistema operacional: coordenar o uso compartilhado de recursos físicos, escalonando a execução de processos.

Quando um programa performa uma ação, usualmente estamos falando sobre alguma interação com o recurso físico. Ler ou escrever em um arquivo no hard drive, enviar ou receber dados pela rede, exibir ou receber dados da tela, ouvir ou tocar som - Input e Output - I/O todas essas ações são mediadas pelo sistema operacional. Falar que um programa faz alguma coisa, é atribuir mais responsabilidade do que o programa realmente tem, a única coisa que um processo pode fazer é pedir ao sistema operacional para fazer algo por ele. Esses pedidos são chamados de system calls.

Cada sistema operacional tem seu próprio conjunto de system calls que programas executando nele usam para realizar I/O. No Linux, você pode executar man 2 syscalls no prompt de comando. Não vamos dar atenção à diferença entre os sistemas operacionais porque a biblioteca padrão abstrai essa diferença para nós.

Escrevendo no Mundo

Uma metáfora pertinente para explicar I/O é pensar nele como um diálogo, uma conversa entre um processo e o sistema operacional, e podemos pensar no handle3 como um identificador para essa conversa.

Um handle de um arquivo recebe nomes diferentes no Windows e no Linux — no Windows é chamado de file handle e no Linux é chamado de file descriptor, em geral abreviado como fd. OCaml não possui uma abstração nativa de handle, operações de I/O são realizadas através de channels. Um in_channel é semelhante a um conduto pelo qual os dados fluem de uma fonte externa para o ambiente OCaml.

Nosso primeiro contato com um file handle vai ser uma breve operação de I/O que usa operações básicas do módulo stdlib:

blog/bin/main.ml
let write_greeting_file =
  let dir = get_dir "greetings" in
  let file = dir ^ "/greeting.txt" in
  let oc = open_out file in
  output_string oc "Hello, world!";
  close_out oc;
  file
;;

Adicione esse snippet ao seu arquivo main.ml, faça o load do arquivo novamente no utop, execute a função write_greeting_file, então olhe o arquivo criado.

O único argumento que a função open_out recebe é o caminho para o arquivo que queremos abrir. Se o arquivo não existir, ele será criado. Em sua implementação, open_out usa a função open_out_gen que possui o tipo:

val open_out_gen : open_flag list -> int -> string -> out_channel

o que essa assinatura significa é que como primeiro argumento a função open_out_gen recebe uma lista de flags, do tipo open_flag, essa flag sinaliza nossa intenção com o arquivo.

type open_flag =
    Open_rdonly | Open_wronly | Open_append
  | Open_creat | Open_trunc | Open_excl
  | Open_binary | Open_text | Open_nonblock

Por que precisamos especificar previamente se estamos abrindo esse arquivo para leitura ou escrita? Lembre-se de que não estamos abrindo o arquivo diretamente; estamos pedindo ao sistema operacional para abrir o arquivo para nós. A resposta da pergunta reside na responsabilidade de mediação e multiplexing do sistema operacional.

  • O sistema de arquivo pode ter alguma restrição de segurança, por exemplo, permitir que um processo leia um arquivo, mas não permitir que ele escreva nele. O sistema operacional é o responsável por aplicar essas restrições, e ele faz a checagem de permissões no momento que o arquivo é aberto.
  • Ou nosso processo pode não ser o único acessando o arquivo. Dois processos podem ler um arquivo simultaneamente, mas dois processos tentando escrever no mesmo arquivo ao mesmo tempo pode resultar em um desastre. O sistema operacional mantém um controle de todos os file handles, e se são de leitura ou escrita, para evitar conflitos.

O segundo parâmetro da função open_out_gen é um inteiro que representa as permissões do arquivo, o valor é dado em octal, por exemplo, 0o775, que é o valor que usamos na função get_dir. O terceiro parâmetro é o caminho para o arquivo que queremos abrir. A implementação de open_out usa as flags Open_wronly, Open_creat, Open_trunc, e Open_text, e como permissão usa 0o666, isso significa que o arquivo será aberto para escrita, se não existir, ele será criado, se existir, ele será truncado, e o arquivo será aberto em modo texto, a permissão 0o666 significa que o arquivo será criado com permissões de leitura e escrita para o dono do arquivo e para o grupo do dono.

O resultado da função open_out é um out_channel, que é um tipo abstrato que representa um canal de saída, ao qual demos o nome de oc. output_string precisa saber o out_channel para onde escrever.

A função open_out_gen under the hood usa a função open do Linux4, o retorno da função open é um file descriptor, que é um inteiro, um número identificado que o sistema operacional atribuiu ao arquivo que abrimos, de certo modo, podemos pensar nessa relação sendo equivalente ao CPF de uma pessoa, o CPF é um identificador único para cada pessoa. Temos que passar esse número como argumento para toda chamada subsequente ao sistema que pertence a essa interação específica mediada pelo sistema operacional com o arquivo.

Escrever uma mensagem no console é uma forma de I/O, e também envolve um out_channel, no utop podemos usar a função como:

print_string "hello";;

A função print_string é uma especialização da função mais geral chamada output_string, que não por coincidência, é a mesma função que fizemos uso para escrever no arquivo. print_string é definida em termos de output_string e stdout.

val print_string : string -> unit
let print_string s = output_string stdout s

O que é o stdout? Quando o sistema operacional inicia um processo, ele cria por padrão alguns locais "default" para onde o processo ler e escrever. The standard output stream é um desses, e o stdout é um channel para ele.

let stdout = open_descriptor_out 1

Cada processo possui seu próprio stdout. O que acontece quando um processo escreve no standard output stream? Vai depender do contexto. Geralmente pensamos nisso como "como você imprime mensagens no terminal", porque se executarmos um programa no command prompt, isso é o que acontecerá.

Suponha que main.ml tenha o seguinte conteúdo:

bin/main.ml
let () =
  print_string "Greeting!";

Quando você executa o programa, você verá que a mensagem é imprimida no terminal.

dune exec blog
Greeting! /blog#

Mas lembre-se, um processo nunca faz nada por si só, ele pede ao sistema operacional para fazer algo por ele. Toda ação de I/O é mediada pelo sistema operacional, e stdout não é uma exceção. Se iniciarmos um processo em um contexto onde a saída é piped para um arquivo, por exemplo, então o que é impresso no stdout não será escrito no terminal. O mesmo print_string sendo escrito em um arquivo:

$ dune exec blog > greet.txt
$ cat greet.txt
Greeting!

Processo rodando em background como servers frequentemente escrevem seus logs para o stdout com a expectativa que o sistema operacional irá armazenar esses logs no log do daemon.

Fechando um channel, uma vez que acabamos de escrever, usamos o close_out para avisar ao sistema operacional que não precisamos mais do handle. Para o nosso pequeno exemplo não é necessário fechar o channel, porque o programa termina logo após a escrita, e ao término do programa o sistema operacional fecha todos os handles que o processo possui. Mas em um programa maior, com um tempo de execução mais longo, é importante fechar os handles que não são mais necessários, porque o sistema operacional precisa manter o controle dos processos em memória, e se o número de handles associados com o processo crescer indefinidamente, o sistema operacional pode ficar sem memória.

Conclusão e Perspectivas Futuras

Ao concluirmos esta introdução ao OCaml, estabelecemos as fundações essenciais para a programação na linguagem, desde a configuração do ambiente até a exploração inicial de operações de entrada e saída. Esta viagem nos proporcionou uma visão abrangente da interação entre o OCaml e o sistema operacional, bem como das capacidades fundamentais de I/O, preparando o terreno para explorações futuras.

Este é apenas o início. Nos próximos capítulos, mergulharemos em conceitos mais avançados e exploraremos a vasta gama de funcionalidades que o OCaml tem a oferecer. Aguarde a continuação desta série, onde aprofundaremos nosso conhecimento e habilidades, visando a criação de aplicações complexas e eficientes em OCaml. A jornada pela programação em OCaml continua, prometendo descobertas mais profundas e enriquecedoras. Até lá, convido você a experimentar, explorar e se familiarizar ainda mais com o que já aprendemos.

Footnotes

  1. utop é um ambiente interativo para OCaml que supera o padrão ocaml REPL em termos de funcionalidade e usabilidade. Para mais detalhes, veja a documentação oficial aqui.

  2. Um handle é uma referência abstrata, destinado a servir como ponteiro para um recurso específico. Este recurso pode variar desde um bloco de memória ou objeto gerenciado por outro sistema, tais como sistema operacional ou um banco de dados. Em suma, o handle facilita o acesso e manipulação desses recursos sem exigir do programador um conhecimento detalhado sobre a implementação interna do recurso em questão.

  3. Um Processo é, em essência, uma abstração concebida pelo sistema operacional, representando a execução de um programa. Em qualquer instante no tempo, podemos resumir um processo fazendo um inventário das diferentes partes do sistema que ele acessa ou afeta durante o curso de sua execução. Para compreender o que constitui um processo, temos que entender o que é um machine state: o que um programa pode ler ou escrever enquanto está em execução. Quais componentes da machine são importantes para a execução do programa? As instruções residem na memory, assim como dados manipulados pelo programa — com seus address spaces — é obviamente um componente importante do machine state. Register também constituem o machine state, com diversas instruções que efetuam leituras e escritas explicitamente nos registers. Notavelmente, certos registers especiais são cruciais para a composição do machine state. Exemplos incluem, o program counter que aponta para a próxima instrução a ser executada, o stack pointer e o frame pointer que são usados para gerenciar a stack para parâmetros de chamadas de funções, variáveis locais e endereços de retorno. Finalmente, o programa frequentemente interage com dispositivos de armazenamento persistente. Essas operações de I/O podem incluir uma lista de arquivos que o processo tem abertos atualmente.

  4. A bem da verdade, esse bind depende do sistema operacional que está sendo utilizado.