C# Ler e Escrever

Na aula Arquivos, cada método da classe File fazia tudo de uma vez: abria, lia (ou gravava) o arquivo inteiro e fechava. Às vezes você precisa de mais controle: gravar uma linha agora e outra daqui a pouco, ler um arquivo enorme sem carregá-lo todo, ou ler um arquivo e escrever outro ao mesmo tempo. Para isso existem o StreamWriter ("escritor de fluxo") e o StreamReader ("leitor de fluxo"). Nesta aula você vai usá-los no diário e na lista de alunos da Ana, e vai entender por que um arquivo pode ficar vazio quando você esquece de fechá-lo.

Você vai aprender
  • Gravar aos poucos com StreamWriter: WriteLine e Write.
  • Ler linha por linha com StreamReader.ReadLine e o laço while ((linha = …) != null).
  • Por que o using é obrigatório, e a diferença entre a declaração using e o bloco using (…) { }.
  • O que é o buffer, e o que fazem o Flush e o Dispose.
  • Acrescentar no fim (append: true), ler tudo com ReadToEnd e escolher a codificação.
  • Ler um arquivo e gravar outro ao mesmo tempo.

Esta aula usa using e IDisposable, while e referências anuláveis (string?). Os programas rodam com dotnet run no .NET 10 da máquina da Ana, na pasta ~/cs.


A ideia: o balde e a mangueira

O File.ReadAllText é um balde: ele pega toda a água de uma vez e entrega o balde cheio. Funciona bem para pouca água. Para uma piscina, o balde não aguenta.

Um stream ("fluxo", "correnteza") é uma mangueira: a água passa aos poucos, e você usa cada pedaço quando ele chega. Você abre a torneira (cria o leitor ou o escritor), usa a água pelo tempo que quiser e, no fim, fecha a torneira. Esquecer de fechar é o erro mais comum desta aula.

ClasseTraduçãoPara que serveMétodos principais
StreamWriterescritor de fluxoGravar texto num arquivo, aos poucosWriteLine, Write, Flush
StreamReaderleitor de fluxoLer texto de um arquivo, aos poucosReadLine, ReadToEnd, EndOfStream

Os dois ficam em System.IO, que já vem incluído. Você já conhece o jeito de usá-los: o WriteLine e o Write do StreamWriter funcionam como os do Console, e o ReadLine do StreamReader funciona como o Console.ReadLine. A diferença é que, em vez da tela e do teclado, o texto vai para um arquivo e vem de um arquivo.


1. Gravar aos poucos: StreamWriter

A Ana escreve um diário de estudos. Cada linha é gravada com um comando:

using StreamWriter escritor = new StreamWriter("diario.txt");
escritor.WriteLine("Segunda: estudei C#");
escritor.Write("Terça: ");
escritor.WriteLine("fiz a lista de compras");
escritor.WriteLine($"Total de dias: {2}");
Console.WriteLine("Diário gravado");
new StreamWriter("diario.txt")
Abre o arquivo para escrita. Como no File.WriteAllText, se o arquivo não existe ele é criado, e se existe é esvaziado logo na abertura.
using StreamWriter escritor = …;
A declaração using C# 8: "quando a variável escritor sair de cena, feche o arquivo". Aqui, ela sai de cena no fim do programa. A aula using e IDisposable explica o mecanismo; a seção 3 mostra por que ele é obrigatório.
escritor.WriteLine("…")
Grava o texto e uma quebra de linha, como o Console.WriteLine.
escritor.Write("Terça: ")
Grava o texto sem quebra de linha. O próximo WriteLine continua na mesma linha.
$"Total de dias: {2}"
A interpolação funciona igual: qualquer string serve.
$ dotnet run diario.cs
Diário gravado
$ cat diario.txt
Segunda: estudei C#
Terça: fiz a lista de compras
Total de dias: 2

Repare que nada do diário apareceu na tela: só o Console.WriteLine escreve na tela. O escritor.WriteLine escreve no arquivo.


2. Ler linha por linha: StreamReader

using StreamReader leitor = new StreamReader("diario.txt");
string? linha;
int n = 0;
while ((linha = leitor.ReadLine()) != null)
{
    n++;
    Console.WriteLine($"{n}: {linha}");
}
Console.WriteLine($"Fim: {n} linhas");
using StreamReader leitor = new StreamReader("diario.txt");
Abre o arquivo para leitura, com a posição no começo. Se ele não existe, lança FileNotFoundException (aula Arquivos).
string? linha;
O ? diz que a variável pode guardar null (aula Referências Anuláveis). Ela precisa poder, porque é assim que o leitor avisa o fim.
leitor.ReadLine()
Lê a próxima linha, sem a quebra, e avança. Quando não há mais linhas, devolve null.
while ((linha = leitor.ReadLine()) != null)
A forma clássica, lida de dentro para fora: lê uma linha, guarda em linha e compara o resultado com null. Os parênteses em volta da atribuição são obrigatórios. Se não for o fim, executa o corpo e volta para ler a próxima.
$ dotnet run ler.cs
1: Segunda: estudei C#
2: Terça: fiz a lista de compras
3: Total de dias: 2
Fim: 3 linhas

Em cada volta do laço, só uma linha está na memória. Com um arquivo de 2 GB, este programa funciona igual, e gasta a mesma memória.

Outras formas de ler

CódigoO que faz
leitor.ReadLine()A próxima linha, ou null no fim
leitor.ReadToEnd()Tudo o que falta, numa string (como o File.ReadAllText)
leitor.EndOfStreamtrue quando não há mais nada para ler
leitor.Read()Um caractere, como int; -1 no fim

Com o EndOfStream ("fim do fluxo"), o laço também pode ser escrito assim:

using StreamReader leitor = new StreamReader("diario.txt");
while (!leitor.EndOfStream)
{
    Console.WriteLine(leitor.ReadLine());
}
$ dotnet run fim.cs
Segunda: estudei C#
Terça: fiz a lista de compras
Total de dias: 2

As duas formas funcionam. A do != null é a mais usada, porque já deixa a linha na variável.


3. O buffer, o Flush e o fechar

Ir ao disco é lento, comparado com a memória. Por isso o StreamWriter não grava cada WriteLine no disco na hora: ele junta o texto numa área de memória, o buffer ("reservatório"), e só manda para o disco quando o buffer enche, quando você pede (Flush, "descarga") ou quando o arquivo é fechado (Dispose). O tamanho do arquivo mostra isso:

using StreamWriter escritor = new StreamWriter("parcial.txt");
escritor.WriteLine("primeira linha");
Console.WriteLine($"Antes do Flush: {new FileInfo("parcial.txt").Length} bytes");
escritor.Flush();
Console.WriteLine($"Depois do Flush: {new FileInfo("parcial.txt").Length} bytes");
$ dotnet run flush.cs
Antes do Flush: 0 bytes
Depois do Flush: 15 bytes
new FileInfo("parcial.txt").Length
O tamanho do arquivo no disco, em bytes (a classe FileInfo aparece na aula Pastas e Caminhos).
Antes do Flush: 0 bytes
O WriteLine já foi executado, mas a linha ainda está no buffer.
escritor.Flush();
Manda o que está no buffer para o disco: primeira linha (14 letras) mais a quebra de linha = 15 bytes.

O Dispose, que o using chama sozinho, faz o Flush e depois fecha o arquivo. Você quase nunca precisa chamar o Flush: basta fechar o arquivo na hora certa. E a hora certa importa. Veja este programa, que grava o diário e lê de volta logo depois:

using StreamWriter escritor = new StreamWriter("diario.txt");
escritor.WriteLine("Segunda: estudei C#");
escritor.WriteLine("Terça: fiz a lista de compras");
escritor.WriteLine("Quarta: li um livro");

using StreamReader leitor = new StreamReader("diario.txt");
string? linha;
int n = 0;
while ((linha = leitor.ReadLine()) != null)
{
    n++;
    Console.WriteLine($"{n}: {linha}");
}
Console.WriteLine($"Fim: {n} linhas");
$ dotnet run diario2.cs
Fim: 0 linhas
$ cat diario.txt                « depois que o programa terminou, as linhas estão lá
Segunda: estudei C#
Terça: fiz a lista de compras
Quarta: li um livro

A declaração using só fecha o escritor no fim do programa. Quando o leitor abriu o arquivo, as três linhas ainda estavam no buffer, e o arquivo estava vazio. Para fechar o escritor antes, use o bloco using: o arquivo é fechado na chave }.

using (StreamWriter escritor = new StreamWriter("diario.txt"))
{
    escritor.WriteLine("Segunda: estudei C#");
    escritor.WriteLine("Terça: fiz a lista de compras");
    escritor.WriteLine("Quarta: li um livro");
}                               // aqui o escritor é fechado
FormaFecha quando…Use quando…
using StreamWriter e = new(…);termina o bloco em que a variável está (no programa de cima, o fim do programa)O arquivo fica aberto até o fim mesmo
using (StreamWriter e = new(…)) { … }chega a chave } do blocoVocê vai usar o arquivo de novo depois (ler o que gravou, abrir outra vez)

Missão: rode o programa e veja o Fim: 0 linhas. Troque a declaração using do escritor por um bloco using (StreamWriter escritor = new StreamWriter("diario.txt")) { … } com os três WriteLine dentro, para o arquivo ser fechado antes da leitura. A saída deve terminar com 3: Quarta: li um livro e Fim: 3 linhas.

using StreamWriter escritor = new StreamWriter("diario.txt");
escritor.WriteLine("Segunda: estudei C#");
escritor.WriteLine("Terça: fiz a lista de compras");
escritor.WriteLine("Quarta: li um livro");

using StreamReader leitor = new StreamReader("diario.txt");
string? linha;
int n = 0;
while ((linha = leitor.ReadLine()) != null)
{
    n++;
    Console.WriteLine($"{n}: {linha}");
}
Console.WriteLine($"Fim: {n} linhas");
using (StreamWriter escritor = new StreamWriter("diario.txt"))
{
    escritor.WriteLine("Segunda: estudei C#");
    escritor.WriteLine("Terça: fiz a lista de compras");
    escritor.WriteLine("Quarta: li um livro");
}

using StreamReader leitor = new StreamReader("diario.txt");
string? linha;
int n = 0;
while ((linha = leitor.ReadLine()) != null)
{
    n++;
    Console.WriteLine($"{n}: {linha}");
}
Console.WriteLine($"Fim: {n} linhas");

4. Acrescentar, atalhos e codificação

O StreamWriter esvazia o arquivo ao abrir. Para gravar no fim, como num log, passe append: true ("acrescentar"). A classe File também tem três atalhos que criam leitores e escritores:

using (StreamWriter log = new StreamWriter("log.txt", append: true))
{
    log.WriteLine("Programa iniciado");
}
using (StreamWriter log = File.AppendText("log.txt"))
{
    log.WriteLine("Venda: caderno");
}
using (StreamReader leitor = File.OpenText("log.txt"))
{
    Console.Write(leitor.ReadToEnd());
}
new StreamWriter("log.txt", append: true)
Abre para escrita com a posição no fim: o que já existe fica. Se o arquivo não existe, é criado.
File.AppendText("log.txt")
Atalho para o mesmo StreamWriter com append: true.
File.OpenText("log.txt")
Atalho para new StreamReader("log.txt"). Existe também o File.CreateText, atalho para new StreamWriter(…) sem append.
leitor.ReadToEnd()
Lê tudo o que falta de uma vez.
$ dotnet run log.cs
Programa iniciado
Venda: caderno
$ dotnet run log.cs
Programa iniciado
Venda: caderno
Programa iniciado
Venda: caderno

Outra codificação

Como os métodos da classe File, o leitor e o escritor usam UTF-8 se você não disser nada (aula Arquivos). Para um arquivo em Latin-1, passe a codificação no construtor:

using System.Text;

using (StreamWriter w = new StreamWriter("antigo.txt", false, Encoding.Latin1))
{
    w.WriteLine("São Paulo");
}
Console.WriteLine($"{new FileInfo("antigo.txt").Length} bytes");

using StreamReader r = new StreamReader("antigo.txt", Encoding.Latin1);
Console.WriteLine(r.ReadLine());
$ dotnet run latin.cs
10 bytes
São Paulo
$ file antigo.txt
antigo.txt: ISO-8859 text

O segundo argumento do escritor, false, é o append. Em Latin-1, ã ocupa 1 byte: São Paulo (9) + quebra de linha (1) = 10 bytes. Em UTF-8 seriam 11.


5. Ler um arquivo e escrever outro

É aqui que os streams brilham. A Ana tem as notas da turma em alunos.csv e quer um arquivo aprovados.csv só com quem tirou 7 ou mais. O programa lê uma linha, decide e grava, sem carregar nenhum dos dois arquivos inteiros:

$ cat alunos.csv
nome;nota
Ana;9,5
Bruno;6,5
Carla;8
Diego;5
Elisa;10
using StreamReader leitor = new StreamReader("alunos.csv");
using StreamWriter escritor = new StreamWriter("aprovados.csv");

string? cabecalho = leitor.ReadLine();
escritor.WriteLine(cabecalho);

int aprovados = 0;
string? linha;
while ((linha = leitor.ReadLine()) != null)
{
    string[] campos = linha.Split(';');
    double nota = double.Parse(campos[1]);
    if (nota >= 7)
    {
        escritor.WriteLine(linha);
        aprovados++;
    }
}
Console.WriteLine($"{aprovados} aprovados gravados em aprovados.csv");
dois using seguidos
Um leitor e um escritor abertos ao mesmo tempo, em arquivos diferentes. No fim do programa, os dois são fechados, na ordem inversa da abertura (primeiro o escritor).
leitor.ReadLine() antes do laço
Lê só a primeira linha, o cabeçalho nome;nota, e o copia para o arquivo novo. O laço começa da segunda linha.
double.Parse(campos[1])
As notas usam vírgula, como no Brasil, e o computador da Ana está em português: o Parse entende 9,5. Se o arquivo usasse ponto, seria preciso o CultureInfo.InvariantCulture (aula Cultura).
escritor.WriteLine(linha)
Copia a linha original, sem mudar nada, para o arquivo de aprovados.
$ dotnet run aprovados.cs
3 aprovados gravados em aprovados.csv
$ cat aprovados.csv
nome;nota
Ana;9,5
Carla;8
Elisa;10

File ou Stream: qual usar

Você quer…Use
Ler ou gravar um arquivo pequeno de uma vezFile.ReadAllText, File.WriteAllText e companhia (aula Arquivos)
Passar pelas linhas de um arquivo grandeFile.ReadLines ou StreamReader.ReadLine
Gravar muitas linhas, aos poucos, num laçoStreamWriter (um File.AppendAllText por linha abriria e fecharia o arquivo a cada volta)
Ler um arquivo e gravar outro ao mesmo tempoStreamReader + StreamWriter
Ler um caractere por vezStreamReader.Read()
Ler sem travar o programaReadLineAsync, WriteLineAsync (aula async e await)

Quando dá errado

O arquivo ficou vazio: faltou o using
StreamWriter escritor = new StreamWriter("esqueci.txt");
escritor.WriteLine("linha importante");
Console.WriteLine("Gravei?");
$ dotnet run esqueci.cs
Gravei?
$ ls -l esqueci.txt
-rw-rw-r-- 1 ana ana 0 out 17 12:43 esqueci.txt
$ cat esqueci.txt
$ 

O que aconteceu: o arquivo foi criado (o new cria), mas tem 0 bytes. A linha ficou no buffer, e ninguém chamou o Dispose nem o Flush. Quando o programa terminou, o buffer sumiu junto com a memória. Não há erro nem aviso: o programa diz "Gravei?" e o dado se perdeu.

Como resolver: todo StreamWriter nasce com using: using StreamWriter escritor = new StreamWriter("esqueci.txt");. O using fecha o arquivo até se o programa lançar uma exceção no meio.

Avisos CS8600 e CS8602: string sem ?
using StreamReader leitor = new StreamReader("diario.txt");
string linha = leitor.ReadLine();
Console.WriteLine(linha.ToUpper());
$ dotnet run aviso.cs
/home/ana/cs/aviso.cs(2,16): warning CS8600: Conversão de literal nula ou possível valor nulo em tipo não anulável.
/home/ana/cs/aviso.cs(3,19): warning CS8602: Desreferência de uma referência possivelmente nula.
SEGUNDA: ESTUDEI C#

O que aconteceu: o programa rodou, mas o compilador avisou duas vezes. O ReadLine devolve string?, porque pode devolver null no fim do arquivo, e a variável foi declarada como string, que "nunca é null" (CS8600). Por isso o linha.ToUpper() pode falhar com NullReferenceException se o arquivo estiver vazio (CS8602). Em inglês: Converting null literal or possible null value to non-nullable type.

Como resolver: declare string? linha e confira o null antes de usar: no laço while ((linha = leitor.ReadLine()) != null), ou com if (linha != null). Veja a aula Avisos do Compilador.

ObjectDisposedException: ler depois de fechar
StreamReader leitor = new StreamReader("diario.txt");
leitor.Dispose();
Console.WriteLine(leitor.ReadLine());
$ dotnet run fechado.cs; echo "código: $?"
Unhandled exception. System.ObjectDisposedException: Cannot read from a closed TextReader.
Object name: 'StreamReader'.
   at System.IO.StreamReader.<ThrowIfDisposed>g__ThrowObjectDisposedException|73_0()
   at System.IO.StreamReader.ReadLine()
   at Program.<Main>$(String[] args) in /home/ana/cs/fechado.cs:line 3
código: 134

O que aconteceu: "Não é possível ler de um TextReader fechado". Depois do Dispose, o leitor não serve mais. Com o bloco using (…) { }, isso acontece quando você usa a variável fora das chaves (mas aí o compilador já reclama: a variável nem existe fora do bloco, erro CS0103).

Como resolver: faça todas as leituras antes de fechar. Para ler de novo, abra um leitor novo.

Dois escritores no mesmo arquivo: uma linha sumiu
using StreamWriter a = new StreamWriter("dois.txt");
using StreamWriter b = new StreamWriter("dois.txt");
a.WriteLine("de a");
b.WriteLine("de b");
Console.WriteLine("ok");
$ dotnet run dois.cs
ok
$ cat dois.txt
de a

O que aconteceu: no Linux, os dois escritores abriram o mesmo arquivo, cada um com o seu buffer e a sua posição no começo. No fim, o b foi fechado primeiro e gravou de b no começo do arquivo; depois o a fechou e gravou de a por cima. Nenhum erro. (No Windows, o segundo new StreamWriter lançaria IOException, dizendo que o arquivo está sendo usado por outro processo.)

Como resolver: um arquivo, um escritor. Se duas partes do programa precisam gravar no mesmo arquivo, passe o mesmo StreamWriter para as duas.


Referência rápida

CódigoO que faz
using StreamWriter e = new StreamWriter(c);Abre para gravar (cria ou esvazia); fecha no fim do bloco
new StreamWriter(c, append: true) / File.AppendText(c)Abre para gravar no fim
File.CreateText(c)Atalho para new StreamWriter(c)
e.WriteLine(texto) / e.Write(texto)Grava com / sem quebra de linha
e.Flush()Manda o buffer para o disco agora
using StreamReader l = new StreamReader(c); / File.OpenText(c)Abre para ler
while ((linha = l.ReadLine()) != null)Lê linha por linha até o fim (string? linha)
l.ReadToEnd()O resto do arquivo numa string
l.EndOfStreamtrue no fim
new StreamReader(c, Encoding.Latin1)Lê com outra codificação (using System.Text;)
using (…) { … }Fecha na chave }

Exercícios

1. O fim do arquivo

O que o ReadLine() de um StreamReader devolve quando não há mais linhas?

2. Gravar no fim

Complete para abrir o log sem apagar o que já está nele:

using StreamWriter log = new StreamWriter("log.txt", append: );

3. A palavra que fecha

Complete para o arquivo ser fechado sozinho no fim do bloco:

StreamReader leitor = new StreamReader("diario.txt");

4. Um programa cria um StreamWriter sem using, grava 3 linhas e termina. O que tem no arquivo?

5. Por que a variável do laço de leitura é string?, e não string?

6. O programa grava um arquivo com using StreamWriter e = …; (declaração) e, nas linhas seguintes, lê o mesmo arquivo. A leitura acha o arquivo vazio. Qual é o conserto?

Missão final: os aprovados

Missão: o programa copia o alunos.csv (na outra aba) inteiro para aprovados.csv e depois mostra o arquivo novo. Mude o laço para gravar só os alunos com nota 7 ou mais: separe a linha com Split(';'), converta a nota com double.Parse e grave a linha só se ela passar. A saída deve ser exatamente:

3 aprovados
nome;nota
Ana;9,5
Carla;8
Elisa;10
using (StreamReader leitor = new StreamReader("alunos.csv"))
using (StreamWriter escritor = new StreamWriter("aprovados.csv"))
{
    escritor.WriteLine(leitor.ReadLine());
    int aprovados = 0;
    string? linha;
    while ((linha = leitor.ReadLine()) != null)
    {
        escritor.WriteLine(linha);
        aprovados++;
    }
    Console.WriteLine($"{aprovados} aprovados");
}
Console.Write(File.ReadAllText("aprovados.csv"));
nome;nota
Ana;9,5
Bruno;6,5
Carla;8
Diego;5
Elisa;10
using (StreamReader leitor = new StreamReader("alunos.csv"))
using (StreamWriter escritor = new StreamWriter("aprovados.csv"))
{
    escritor.WriteLine(leitor.ReadLine());
    int aprovados = 0;
    string? linha;
    while ((linha = leitor.ReadLine()) != null)
    {
        string[] campos = linha.Split(';');
        double nota = double.Parse(campos[1]);
        if (nota >= 7)
        {
            escritor.WriteLine(linha);
            aprovados++;
        }
    }
    Console.WriteLine($"{aprovados} aprovados");
}
Console.Write(File.ReadAllText("aprovados.csv"));

Os dois using (…) seguidos, com um bloco só, abrem os dois arquivos e fecham os dois na mesma chave }. Por isso o File.ReadAllText do fim já encontra o aprovados.csv completo. Se quiser ir além: grave também um reprovados.csv com os outros, usando um segundo escritor.


Resumo

  • StreamWriter grava aos poucos (WriteLine, Write); StreamReader lê aos poucos (ReadLine, ReadToEnd).
  • O laço de leitura é while ((linha = leitor.ReadLine()) != null), com string? linha: o null marca o fim do arquivo.
  • O escritor guarda o texto num buffer e só grava no disco no Flush ou no Dispose. Sem using, o arquivo pode ficar vazio.
  • A declaração using fecha no fim do bloco em que está; o bloco using (…) { } fecha na chave }. Feche o escritor antes de ler o mesmo arquivo.
  • append: true (ou File.AppendText) grava no fim; a codificação é UTF-8, a não ser que você passe outra.
  • Um leitor e um escritor juntos processam um arquivo inteiro gastando a memória de uma linha.