Материалы по языку C#

Содержание

  1. Простейшая консольная программа
  2. Простейшая графическая программа
  3. Делегаты (delegate)
  4. События (event)
  5. Параллельное программирование: нити (Threads)
  6. Синхронизация нитей и процессов
  7. Работа с файлами

1. Простейшая консольная программа

Обычно в качестве первой программы на новом языке программирования предлагается "Hello, world!". Мы рассмотрим чуть более содержательную программу, которая печтает на консоли текущее время.

using System;
class ShowTime
{
    static int Main(string[] args)
    {
        Console.WriteLine("Current time: {0}", DateTime.Now);
        return 0;
    }
}
Здесь "System" -- это пространство имен (namespace), в котором содержатся все стандартные классы. Основной единицей программы является класс, в языке C# вообще нет функций в стиле языка C. Вместо них используются статические методы классов, т.е. методы, которые не связаны с объектами. В нашей программе мы описываем класс "ShowTime". Метод "Main" -- это статический метод класса, с которого начинается работа программы. (Метод "Main" обязан быть статическим, потому что в начале программы никакие объекты еще не созданы!)

"Console" -- это класс в пространстве имен "System", отвечающий за консольный ввод-вывод. Статический метод "WriteLine" класса "Console" печатает текст и затем переводит курсор в начало следующей строки. Первый агрумент метода "WriteLine" -- это форматная строка, содержащая обычный текст, а также символы формата вида {n}, где n -- числа 0, 1, 2, ... Вместо форматов подставляются текстовые представления объектов, которые являются остальными аргументами метода. Вместо "{0}" подставляется значение второго аргумента, вместо "{1}" -- третьего и т.д.

Сложнее всего понять, что такое "DateTime.Now". "DateTime" -- это класс, отвечающий за представление времени. "Now" -- это так называемое "свойство" класса ("property"). "Свойство" класса в языке C# -- это некоторая воображаемая переменная-член класса, которой на самом деле нет, а есть вместо этого есть два метода "get" и "set". Первый вызывается при присвоении значения свойству, второй -- при чтении значения свойства. Извне свойство выглядит и используется как обычная переменная. Если метод "set" отсутствует, то эту переменную можно только читать, но нельзя присваивать ей значение. Например, описание свойства "Now" может выглядеть примерно так:

public class DateTime {
    . . .
    public static DateTime Now {
        get {
            // Определяем текущее время и записываем
            // в переменную "res"
            // . . .
            return res; // Возвращаем значение свойства
        }
        // Метод "set" отсутствует, свойство "Now"
        // можно только читать!
    }
    . . .
}

2. Простейшая графическая программа

Архив-файл "Hello.tgz" содержит проект в Microsoft.NET, реализующий приложение "Часы". Это оконная программа, которая рисует циферблат с движущимися стрелками -- часовой, минутной и секундной. Кроме того, изображается текстовое представление текущего времени (для этого используется компонента типа "Label"). Картинка обновляется каждую секунду, для обновления запускается нить (Thread), которая раз в секунду посылает окну сообщение о перерисовке. Таким образом, приложение "Часы" иллюстрирует

3. Делегаты (delegate)

Делегаты в языке C# обобщают указатели на функции в языке C. Есть следующие существенные отличия:

Делегат описывается следующим образом:

    public delegate void LogHandler(string s);
Здесь мы описали тип "LogHandler", который содержит список указателей на методы с аргументом типа string (строка), возвращающие значение типа void (т.е. не возвращающие никакого значения). Это описание может содержаться как вне, так и внутри класса или пространства имен. Описание делегата аналогично описанию нового класса и является лишь более синтаксически удобной формой описания класса, наследуемого из стандартного класса MulticastDelegate.

С делегатом можно выполнять следующие действия:

Рассмотрим следующий пример (файл "DlTst.cs"): имеется класс "Process" с единственным методом "Run", которому передается объект типа LogHandler. Зарегистрированные в нем методы получают информацию о ходе выполнения процесса. В начале выполнения процесса мы вызываем все зарегистрированные методы, передавая им строку "Start procerss", по окончанию работы процесса -- строку "Stop process". Чтобы проиллюстрировать передачу указателей на обычные и статические методы, мы создаем класс "Tracer" с едиственным (не статическим!) методом "Trace", а также класс "Test" со статическим методом "Log", оба метода принимают в качестве единственного параметра текстовую строку. Класс "Test" содержит метод "Main", с которого начинается работа программы. Методы "Trace" и "Log" просто печатают полученную строку, сопровождая ее комментарием, по которому можно определить, какой именно метод был вызван. Вот текст программы.

using System;

public delegate void LogHandler(string s);

public class Process {
    public void Run(LogHandler lh) {
        lh("Start process");
        //...
        lh("Stop process");
    }
}

public class Tracer {
    public void Trace(string s) {
        System.Console.WriteLine("Tracer: {0}", s);
    }
}

public class Test {
    public static void Log(string s) {
        System.Console.WriteLine("static Log: {0}", s);
    }

    public static int Main(string[] args) {
        Process p = new Process();
        Tracer t = new Tracer();
        LogHandler lh = null;
        lh += new LogHandler(Log);
        lh += new LogHandler(t.Trace);
        p.Run(lh);
        return 0;
    }
}

Вот что выводится при работе программы на консоль:

    static Log: Start process
    Tracer: Start process
    static Log: Stop process
    Tracer: Stop process

4. События (event)

Событие "event" -- это частный случай делегата, на который накладываются некоторые ограничения по использованию. Событие можно зажигать только в том классе, в котором оно описано. С событием можно выполнять лишь действия "+=" (добавить метод к списку) и "-=" (удалить метод из списка). Событие служит для оповещения всех, кто в нем заинтересован, путем вызова всех методов из списка. Все заинтересованные в событии методы должны быть зарегистрированы в нем, для этого к переменной типа event применяется операция "+=" (добавить метод), правым аргументом которой является ссылка на метод. Предыдущий пример переписывается с использованием события в более естественной форме (файл "EvTst.cs"):

using System;

public delegate void LogHandler(string s);

public class Process {
    public event LogHandler LogEvent = null;

    public void Run() {
        if (LogEvent != null)
            LogEvent("Start process");

        //...

        if (LogEvent != null)
            LogEvent("Stop process");
    }
}

public class Tracer {
    public void Trace(string s) {
        System.Console.WriteLine("Tracer: {0}", s);
    }
}

public class Test {
    public static void Log(string s) {
        System.Console.WriteLine("static Log: {0}", s);
    }

    public static int Main(string[] args) {
        Process p = new Process();
        Tracer t = new Tracer();
        p.LogEvent += new LogHandler(Log);
        p.LogEvent += new LogHandler(t.Trace);
        p.Run();
        return 0;
    }
}
Здесь класс Process содержит событие с именем LogEvent типа LogHandler. Метод Run зажигает это событие в начале и в конце своего выполнения, передавая строки "Start process" и "Stop process" соотвественно. В методе Main класса Test создаются объекты типа Tracer и Process, к событию LogEvent созданного объекта типа Process добавляются ссылки на статический метод Log класса Test и метод Trace созданного объекта типа Tracer. В конце запускается метод Run оъекта типа Process. В отличие от предыдущего примера, методу Run уже не нужно передавать никаких параметров, что гораздо удобнее. Результат работы такой же, как и в предыдущем примере.

5. Параллельное программирование: нити (Threads)

Пример "Opeл-Решка" (файл "Try.cs"). Создаем 2 нити, одна 4 раза печатает на консоли слово "Орел", другая 4 раза печатает "Решка". В перерывах между печатями каждая нить засыпает на случайное время, равномерно распределенное в интервале от нуля до трех секунд.

using System;
using System.Threading;

public class Try {
    public static Random rand = new Random();
    public String result;

    public Try(String s) { result = s; }

    public void Run() {
        for (int i = 0; i < 4; i++) {
            int dt = rand.Next(3000);
            // Console.WriteLine("dt = {0}", dt);
            Thread.Sleep(dt);
            System.Console.WriteLine(result);
        }
    }

    public static void Main() {
        Try t1 = new Try("Orel");
        Try t2 = new Try("Reshka");
        Thread thread1 = new Thread(new ThreadStart(t1.Run));
        Thread thread2 = new Thread(new ThreadStart(t2.Run));
        thread1.Start();
        thread2.Start();
    }
}

Вот один из вариантов работы программы:

    Orel
    Orel
    Reshka
    Orel
    Reshka
    Orel
    Reshka
    Reshka

Второй пример: Тик-Так (файл "TickTack.cs"). Создается объект типа Timer, которому передаются следующие четыре параметра: 1) делегат, содержащий ссылку на метод, который таймер вызывает через заданные промежутки времени; 2) объект, который будет каждый раз передаваться этому методу; 3) время в миллисекундах перед первым вызовом; 4) промежуток времени между последующими вызовами.

    Timer t = new Timer(callback, new A(), 1000, 1000);
При этом неявно создается фоновая нить, которая оживает в заданные моменты времени и асинхронно вызывает указанный метод.

В примере вызываемый метод печатает "Tick" при нечетных вызовах и "Tack" при четных, вызовы происходят раз в секунду. Вот текст программы:

using System;
using System.Threading;

class A {
    public int n = 0;
}

public class TickTack {
    public static void Main() {
        String line;
        Console.WriteLine("Press Enter to terminate...");
        TimerCallback callback = new TimerCallback(PrintTick);
        Timer t = new Timer(callback, new A(), 1000, 1000);
        line = Console.ReadLine();
    }

    static void PrintTick(Object state) {
        int tick = ((A) state).n;
        ((A) state).n = 1 - tick;

        if (tick == 0)
            Console.WriteLine("tick");
        else
            Console.WriteLine("tack");
    }
}

6. Синхронизация нитей и процессов

6.1. Пример несинхронизированной программы

В программе "SyncTest.cs" массив размера 100 заполняется целыми числами от 1 до 100. Помимо основной нити, создаются еще 10 нитей (с номерами от 1 до 10). Основная нить "перемешивает" массив в течение 10 секунд. Она выбирает случайно 2 индекса i, j в пределах от 0 до 99 и меняет местами элементы массива с этими индексами. Остальные нити проверяют сумму элементов массива. Каждая нить повторяет бесконечно одну и ту же последовательность из двух действий: суммирует массив и сравнивает его сумму с числом 5050. Как только сумма не совпала с числом 5050, нить печатает сообщение об ошибке и выставляет флаг ошибки, по которому все нити завершают работу. При отсутствии ошибки по завершении работы основной нити остальные также завершают работу.

Ошибка возникает из-за того, что в процессе обмена в какой-то момент два элемента временно равны друг другу. Если при этом произойдет переключение нитей, то сумма массива получится некорректной.

Вот текст программы:

using System;
using System.Threading;

public class SyncTest {
    public static int[] a = new int[100];
    public static bool stop = false;
    public static bool incorrect = false;

    public static int Main() {
        int i;
        Thread[] threads = new Thread[10];

        // Fill in the array
        for (i = 0; i < 100; i++)
            a[i] = i + 1;

        for (i = 0; i < 10; i++) {
            threads[i] = new Thread(
                new ThreadStart(CheckSum)
            );
            threads[i].Name = "Thread " + (i + 1).ToString();
            threads[i].Start();
        }

        Mix();

        stop = true;

        if (!incorrect)
            Console.WriteLine("Sum is correct!");

        return 0;
    }

    public static void Mix() {
        Console.WriteLine("mixing...");
        Random rnd = new Random();
        DateTime start = DateTime.Now;
        while (!stop && (DateTime.Now - start).Seconds < 10) {
            int i = rnd.Next(0, 100);
            int j = rnd.Next(0, 100);
            // Console.WriteLine("swapping ({0}, {1})", i, j);
            int tmp = a[i];
            a[i] = a[j];
            a[j] = tmp;
        }
    }

    public static void CheckSum() {
        while (!stop) {
            int s = 0;

            for (int i = 0; i < 100; i++) {
                s += a[i];
            }

            if (s != 5050) {
                Console.WriteLine(
                    "{0}: Sum = {1} -- incorrect!",
                    Thread.CurrentThread.Name, s
                );
                incorrect = true;
                stop = true;
            }
        }
    }
}

Пример выдачи на экран:

    mixing...
    Thread 7: Sum = 3970 -- incorrect!
    Thread 1: Sum = 4151 -- incorrect!
    Thread 2: Sum = 3896 -- incorrect!
    Thread 3: Sum = 4151 -- incorrect!
    Thread 4: Sum = 4853 -- incorrect!
    Thread 5: Sum = 4935 -- incorrect!
    Thread 6: Sum = 3881 -- incorrect!
    Thread 10: Sum = 5017 -- incorrect!
    Thread 9: Sum = 4577 -- incorrect!
    Thread 8: Sum = 4267 -- incorrect!

6.2. Синхронизация с помощью класса Monitor

Рассмотрим тот же пример, что и в предыдущем случае, но для исключения доступа к массиву в момент выполнения обмена двух элементов используем синхронизацию с помощью класса Monitor. Исходный текст программы -- в файле "SyncTst1.cs". Идея состоит в том, что нить захватывает специальный синхронизирующий элемент ("монитор"), связанный с охраняемым объектом. В нашем случае охраняемый объект -- это массив "a". Перед выполнением критической операции выполняется захват монитора:

    Monitor.Enter(a);
По окончании критической операции монитор освобождается:
    Monitor.Exit(a);
В момент первого входа система создает специальный синхронизирующий объект, связаянный с объектом "a", если он до этого не был создан. Если при попытке захвата монитора он уже захвачен, то нить приостанавливает работу и ждет, пока монитор не освободится, потенциально бесконечно долго.

Методы "Monitor.Enter" и "Monitor.Exit" -- это статические методы класса Monitor. Мониторы аналогичны критическим секциям в Windows.

При выполнении критической операции может возникнуть прерывание, в результате чего монитор никогда не будет освобожден! Поэтому критическую секцию надо обязательно помещать внутрь блока "try", а освобождение монитора выполнять внутри блока "finally":

    Monitor.Enter(a);
    try {
        // Текст критической секции
        // . . .
    }
    finally {
        Monitor.Exit(a);
    }

В рассматриваемой программе с помощью монитора защищается участок программы, в котором два элемента массива меняются местами

    Monitor.Enter(a);
    try {
        int tmp = a[i];
        a[i] = a[j];
        a[j] = tmp;
    } finally {
        Monitor.Exit(a);
    }
а также фрагмент, в котором массив суммируется:
    Monitor.Enter(a);
    try {
        for (int i = 0; i < 100; i++) {
            s += a[i];
        }
    } finally {
        Monitor.Exit(a);
    }
В результате две нити не могут одновременно войти в эти критические секции.

Как легко убедиться, при работе синхронизированной программы ошибки не происходит, вот ее выдача:

    mixing...
    Sum is correct!

6.3. Синхронизация с помощью ключевого слова "lock"

Тот же пример, синхронизация с помощью ключевого слова lock (неявно также используется объект типа Monitor): программа "SyncTst2.cs".

Ключевое слово "lock" позволяет записывать синхронизацию с помощью монитора более коротко и изящно. А именно, конструкция

    lock(a) {
        // Текст критической секции
        // . . .
    }
полностью эквивалентна фрагменту кода
    Monitor.Enter(a);
    try {
        // Текст критической секции
        // . . .
    }
    finally {
        Monitor.Exit(a);
    }
но короче и понятнее для начинающих.

6.4. Синхронизация с помощью объекта типа ReaderWriterLock

Тот же пример, синхронизация с помощью объекта типа ReaderWriterLock: программа "SyncTst3.cs".

Объект типа ReaderWriterLock позволяет исключить одновременное чтение/запись или запись/запись охраняемого объекта различными нитями, но разрешить одновременное чтение. Для этого создается экземпляр объекта типа ReaderWriterLock

    static ReaderWriterLock rwlock = new ReaderWriterLock();
Нить, желающая записать информацию в охраняемый объект, сначала должна получить разрешение на запись у объекта rwlock:
    rwlock.AcquireWriterLock(Timeout.Infinite);
Если разрешение не получено, то нить приостанавливается до освобождения охраняемого объекта или до истечения таймаута. Аналогично можно получить разрешение на чтение:
    rwlock.AcquireReaderLock(Timeout.Infinite);
В нашем примере перемешивающая нить просит разрешение на запись, а суммирующие нити -- на чтение. В результате суммирующие нити не мешают друг другу.

Перемешивающая нить выполняет следующий код:

    rwlock.AcquireWriterLock(Timeout.Infinite);
    try {
        // Console.WriteLine("swapping ({0}, {1})", i, j);
        int tmp = a[i];
        a[i] = a[j];
        a[j] = tmp;
    } finally {
        rwlock.ReleaseWriterLock();
    }
Суммирующая нить выполняет код
    rwlock.AcquireReaderLock(Timeout.Infinite);
    try {
        for (int i = 0; i < 100; i++) {
            s += a[i];
        }
    } finally {
        rwlock.ReleaseReaderLock();
    }

6.5. Синхронизация с помощью объектов типа AutoResetEvent и ManualResetEvent

Синхронизация (точнее сказать, сигнализация) при помощи объектов типа AutoResetEvent иллюстрируется программой "SyncTst4.cs". Создаются две нити, одна печатает на консоль нечетные числа от 1 до 999, другая -- четные числа от 2 до 1000. При отсутствии синхронизации печатаются длинные серии из только четных или только нечетных чисел с непредсказуемыми моментами переключения. В синхронизированном варианте первая нить печатает нечетное число, затем сигнализирует второй нити, что она это сделала, и ждет, пока вторая нить напечатает четное число. Вторая нить ждет, пока первая напечатает нечетное число, затем печатает четное число и сигнализирует первой нити, что она это сделала. Каждая нить использует для сигнализации свой объект типа AutoResetEvent (т.е. всего таких объектов два: первый устанавливается в сигнальное состояние первой нитью и ожидается второй нитью; второй устанавливается второй нитью и ожидается первой).

Исходный текст программы:

using System;
using System.Threading;

class EventTst {
    static AutoResetEvent e1 = new AutoResetEvent(false);
    static AutoResetEvent e2 = new AutoResetEvent(false);

    public static void Main() {
        try {
            Thread thread1 = new Thread(
                new ThreadStart(PrintOdds)
            );
            Thread thread2 = new Thread(
                new ThreadStart(PrintEvens)
            );

            thread1.Start();
            thread2.Start();

            thread1.Join();
            thread2.Join();
        } finally {
            e1.Close();
            e2.Close();
        }
    }

    static void PrintOdds() {
        for (int i = 1; i <= 999; i += 2) {
            Console.WriteLine("{0} --", i);
            e1.Set();
            e2.WaitOne(); // Wait until even number is printed
        }
    }

    static void PrintEvens() {
        for (int i = 2; i <= 1000; i += 2) {
            e1.WaitOne(); // Wait until odd number is printed
            Console.WriteLine("-- {0}", i);
            e2.Set();
        }
    }
}

Здесь e1 и e2 -- это два объекта типа AutoResetEvent, которые создаются в начале работы программы:

    static AutoResetEvent e1 = new AutoResetEvent(false);
    static AutoResetEvent e2 = new AutoResetEvent(false);
Объект e1 сигнализирует, что очередное нечетное число напечатано, e2 -- что четное напечатано. Объект e1 устанавливается в сигнальное состояние первой нитью и ожидается второй нитью. Соответственно, e2 устанавливается второй нитью и ожидается первой. Для установки в сигнальное состояние используется метод "Set", для ожидания -- метод "WaitOne". Первая нить выполняет фрагмент кода
    for (int i = 1; i <= 999; i += 2) {
        Console.WriteLine("{0} --", i);
        e1.Set();     // Set e1 in the signal state
        e2.WaitOne(); // Wait until even number is printed
    }
Вторая нить выполняет фрагмент
    for (int i = 2; i <= 1000; i += 2) {
        e1.WaitOne(); // Wait until odd number is printed
        Console.WriteLine("-- {0}", i);
        e2.Set();     // Set e2 in the signal state
    }

По окончанию работы следует обязательно закрыть объекты типа AutoResetEvent, т.к. с ними связаны объекты операционной системы, которые надо явно освобождать, когда они становятся ненужными. Как всегда, это делается в блоке "finally":

    static AutoResetEvent e1 = new AutoResetEvent(false);
    static AutoResetEvent e2 = new AutoResetEvent(false);

    public static void Main() {
        try {
            //... тело метода Main
            //...
        } finally {
            e1.Close();
            e2.Close();
        }
    }

Объект типа ManualResetEvent отличатся от AutoResetEvent тем, что он не переходит автоматически в нормальное (не сигнальное) состояние после выполнения метода WaitOne. Для этого нужно дополнительно вызвать метод "Reset", например

    ManualResetEvent e3 = new ManualResetEvent(false);
    . . .
    e3.WaitOne();
    e3.Reset();
Таким образом, объект типа ManualResetEvent можно использовать для сигнализации о каком-то событии сразу нескольким нитям.

7. Работа с файлами

Для работы с файлами (ввода-вывода) используется пространство имен System.IO:

    using System.IO;

Файлы в C# представляются как потоки байтов. Впрочем, потоками могут быть не только файлы: консоль также представлят собой поток, через потоки огранизуется работа с сетью (в случае сетевых протоколов, предоставляющих гарантированную доставку сообщений, таких, как Internet-протокол TCP).

Имеется абстрактный класс Stream, от которого наследуются все конкретные типы потоков -- FileStream в случае файлов, NetworkStream в случае сетевого обмена, MemoryStream в случае потока в оперативной памяти, CryptoStream для шифрования и BufferedStream для дополнительной буферизации.

Поток может быть открыт только на чтение, только на запись или одновременно на чтение и запись. У класса Stream имеются свойства

    bool CanRead
    bool CanWrite
доступные только для чтения, по которым можно узнать, возможно ли чтение из потока или запись в поток. Кроме того, в некоторых потоках возможно прямое позиционирование, соответствующее свойство -- это
    bool CanSeek
К примеру, прямое позиционирование возможно в потоке типа FileStream, но невозможно в NetworkStream.

Устанавливать текущую позицию можно с помощью метода

    long Seek(long offset, SeekOrigin origin),
где origin может принимать значения SeekOrigin.Begin, SeekOrigin.End, SeekOrigin.Current, или с помощью свойства
    long Position,
доступного как для чтения, так и для записи. Если для потока возможно прямое позиционирование, то можно узнать его длину с помощью свойства
    long Length,
доступного только для чтения, и установить новую длину с помощью метода
    void SetLength(long length)
(в последнем случае поток должен быть также доступен для записи).

Основные методы работы с потоком типа Stream -- это чтение и запись массива байтов:

    int Read(byte[] buffer, int offset, int len);
    void Write(byte[] buffer, int offset, int len);
Здесь len -- это запрашиваемое количество байтов для чтения или записи. Метод Read возвращают реальное число прочитанных байтов или 0 в случае конца файла. Оба метода могут возбуждать исключения типа IOException в случае ошибки ввода-вывода или NotSupportedException, если ввод или вывод для данного потока невозможен (например, когда файл открыт только на чтение).

Объект типа Stream позволяет читать и записывать только байты. Как быть, когда надо читать или писать строки, числа и другие более сложные объекты? Для этого служат классы типа Reader и Writer, например, StreamReader и StreamWriter или BinaryReader и BinaryWriter. При создание экземпляра класса типа Reader указывается уже созданный поток, для которого дополнительно создается "читатель" потока. Пример:

    String fileName;
    . . .
    FileStream stream = null;
    StreamReader reader = null;
    try {
        stream = new FileStream(
            fileName, FileMode.Open, FileAccess.Read,
            FileShare.Read
        );
        reader = new StreamReader(stream);

        . . .

    } catch (Exception e) {
        Console.WriteLine("Cannot open a file: {0}", e.Message);
    }
Здесь мы сначала создаем поток и только потом создаем читателя для него. При создании потока типа FileStream указываются имя файла, способ открытия (открыть имеющийся файл FileMode.Open, создать новый FileMode.Create, открыть имеющийся или создать новый файл при его отсутствии FileMode.OpenOrCreate и другие), тип доступа (только чтение FileAccess.Read, только запись FileAccess.Write, чтение и запись FileAccess.ReadWrite), а также ограничения при разделяемом доступе (недопустимо совместное использование FileShare.None, можно совместно открывать только на чтение FileShare.Read, на чтение и запись FileShare.ReadWrite, только на запись FileShare.Write; этот параметр можно опустить, тогда будет использовано значение по умолчанию FileShare.Read). При невозможности создания потока (т.е. открытия файла) возбуждается исключение одного из возможных типов (FileNotFoundException, IOException, SecurityException и другие).

Можно, однако, сразу создать читателя для файла, при этом поток будет создан неявно. Приведем 2 разных способа:

    StreamReader reader = new StreamReader(fileName);
    StreamReader reader = File.OpenText(fileName);
Во втором случае мы используем статический метод OpenText класса File, который открывает текстовый файл.

Для чтения строки из потока используется метод ReadLine, который считывает байты из входного потока до символа '\n' (или символов "\r\n") перевода строки, не включая этот символ:

    String line = reader.ReadLine();
При этом байты из входного потока преобразуются в символы, составляющие строку (для представления символов в C# используется 16-битовая кодировка Unicode).

Для закрытия потока используется метод Close, например,

    stream.Close();

Приведем два примера программ, работающих с файлами. Первая программа "List.cs" читает текстовый файл и распечатывает его содержимое на консоли. Имя файла указывается как аргумент командной строки. Для чтения содержимого файла сначала создается поток типа FileStream, а затем для него создается "читатель" типа StreamReader, предназначенный для чтения текстового потока. Считанные строки файла выводятся на консоль.

using System;
using System.IO;

class FileIOTest {
    public static int Main(String[] args) {
        int ret = 0;
        if (args.Length == 0) {
            Console.WriteLine("Missing file name");
            return 1;
        }

        FileStream stream = null;
        StreamReader reader = null;
        try {
            stream = new FileStream(
                args[0], FileMode.Open, FileAccess.Read,
                FileShare.Read
            );
            reader = new StreamReader(stream);

            // Other ways:
            // reader = new StreamReader(args[0]);
            // reader = File.OpenText(filename);

            String line = reader.ReadLine();
            while (line != null) {
                Console.WriteLine(line);
                line = reader.ReadLine();
            }
        } catch (Exception e) {
            Console.WriteLine(e.Message);
            ret = 1;
        } finally {
            if (stream != null) {
                stream.Close();
            }
        }
        return ret;
    }
}

Вторая программа "Code.cs" используется для примитивного шифрования содержимого файла. Для шифрования используется ключевое слово, которое вводится как второй аргумент командной строки (первый аргумент -- путь к шифруемому файлу). Для шифрования выполняется побитовая операция сложения по модулю 2, или XOR (исключающее или). Для этого формируется последовательность байтов ключевого слова путем его многократного повторения. Эта последовательность складывается с последовательностью байтов файла. Результат записывается обратно в файл. Так как двукратное побитовое прибавление по модулю 2 одного и того же байта является тождественной операцией

    (x^y)^y == x,
то для дешифровки применяется та же самая команда, что и для шифрования. В программе мы сначала открываем файл для чтения и записи, создавая поток типа FileStream, затем для этого потока создаем его "читателя" типа BinaryReader и "писателя" типа BinaryWriter. Программа считавает из потока блок байтов размером 1024, шифрует его, прибавляя побитово по модулю 2 ключевую последовательность, устанавливает текущую позицию в потоке обратно на начало считанного блока и записывает зашифрованный блок обратно в файл. Вот исходный текст программы.
using System;
using System.IO;
using System.Text;

class FileIOTest {
    public static int Main(String[] args) {
        int ret = 0;
        if (args.Length == 0) {
            Console.WriteLine("Missing file name");
            return 1;
        }
        if (args.Length < 2) {
            Console.WriteLine("Missing key string");
            return 1;
        }

        FileStream stream = null;
        BinaryReader reader = null;
        BinaryWriter writer = null;
        try {
            stream = new FileStream(
                args[0], FileMode.Open, FileAccess.ReadWrite
            );
            reader = new BinaryReader(stream);
            writer = new BinaryWriter(stream);

            byte[] buffer = new byte[1024];
            byte[] key = new ASCIIEncoding().GetBytes(args[1]);
            int keyLen = key.Length;

            long fileSize = stream.Length;
            long processed = 0;
            long pos = 0;

            while (processed < fileSize) {
                int requested = 1024;
                if (requested > fileSize - processed)
                    requested = (int)(fileSize - processed);
                int bytesRead = reader.Read(buffer, 0, requested);
                if (bytesRead <= 0)
                    break;
                for (int i = 0; i < bytesRead; i++) {
                    buffer[i] ^= key[(pos + i) % keyLen];
                }
                stream.Seek(pos, SeekOrigin.Begin);
                writer.Write(buffer, 0, bytesRead);
                processed += bytesRead;
                pos += bytesRead;
            }
        } catch (Exception e) {
            Console.WriteLine(e.Message);
            ret = 1;
        } finally {
            if (stream != null) {
                stream.Close();
            }
        }
        return ret;
    }
}
Отметим, что для преобразования ключевого слова, заданного как аргумент командной строки, в массив байтов мы используем объект типа ASCIIEncoding.
    byte[] key = new ASCIIEncoding().GetBytes(args[1]);
Символы в языке C# используют кодировку Unicode, т.е. каждый символ кодируется двумя байтами, старший из которых для латинских букв равен нулю. Метод GetBytes объекта типа ASCIIEncoding преобразует каждый двухбайтный Unicode-символ строки к однобайтовому ASCII-символу, который и используется при кодировании.