System.Text.EncodingクラスとBOMのあり/なしの指定方法について。 またSystem.IO.StreamWriter内部の動作や、Encodingを指定できるクラスでのBOM出力の動作と制御、その他BOM関連の処理について。

概略

StreamWriterではデフォルトでUTF-8が用いられるほか、StreamWriterコンストラクタの引数encodingEncoding.UTF8プロパティEncoding.DefaultプロパティUTF8Encodingクラスのインスタンスを指定することによりUTF-8で書き込むことができる。

このとき、StreamWriterがBOM(Byte Order Mark)を出力するかどうかは、encodingに指定する値によって次のように変わる。

StreamWriterコンストラクタの引数encodingとBOM出力のありなし
引数encodingに指定する値 BOM出力
(指定なし) なし
Encoding.UTF8 あり
Encoding.Default (.NET Core/.NET 5以降) なし
UTF8Encoding(encoderShouldEmitUTF8Identifier: true) あり
UTF8Encoding(encoderShouldEmitUTF8Identifier: false) なし
StreamWriterコンストラクタの引数encodingとBOM出力のありなし 
using System;
using System.IO;
using System.Text;

foreach (var createStreamWriter in new Func<Stream, StreamWriter>[] {
  // 引数encodingに指定する値を変えてStreamWriterを作成する
  s => new(s), // encodingの指定なし
  s => new(s, encoding: Encoding.UTF8),
  s => new(s, encoding: Encoding.Default), // ⚠.NET FrameworkのEncoding.DefaultはUTF-8ではなくShift_JIS等のANSIコードページとなる
  s => new(s, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)),
  s => new(s, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)),
}) {
  using var stream = new MemoryStream();
  const string text = "日本語";

  using (var writer = createStreamWriter(stream)) {
    writer.Write(text);
  }

  // StreamWriterが出力したバイト列を16進形式で表示
  Console.WriteLine(BitConverter.ToString(stream.ToArray()));
}
StreamWriterコンストラクタの引数encodingとBOM出力のありなし
Option Infer On

Imports System
Imports System.IO
Imports System.Text

Class Sample
  Shared Sub Main()
    ' 引数encodingに指定する値を変えてStreamWriterを作成する
    For Each createStreamWriter In New Func(Of Stream, StreamWriter)() {
      Function(s) New StreamWriter(s), ' encodingの指定なし
      Function(s) New StreamWriter(s, encoding := Encoding.UTF8),
      Function(s) New StreamWriter(s, encoding := Encoding.Default), ' ⚠.NET FrameworkのEncoding.DefaultはUTF-8ではなくShift_JIS等のANSIコードページとなる
      Function(s) New StreamWriter(s, encoding := New UTF8Encoding(encoderShouldEmitUTF8Identifier := True)),
      Function(s) New StreamWriter(s, encoding := New UTF8Encoding(encoderShouldEmitUTF8Identifier := False))
    }
      Using stream As New MemoryStream()
        Const text As String = "日本語"

        Using writer = createStreamWriter(stream)
          writer.Write(text)
        End Using

        ' StreamWriterが出力したバイト列を16進形式で表示
        Console.WriteLine(BitConverter.ToString(stream.ToArray()))
      End Using
    Next
  End Sub
End Class
実行結果
E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E

(強調したEF-BB-BFのシーケンスがUTF-8のBOM)

UTF-8以外の場合も同様の動作となる。 BOMあり/なしで分類すると次のようになる。 Encodingクラスのプロパティとして用意されているものは、Encoding.Defaultを除くとすべてBOMありとなる。

StreamWriterコンストラクタの引数encodingとBOM出力のありなし
エンコーディング 引数encodingに指定する値
BOMあり BOMなし
UTF-8 Encoding.UTF8
UTF8Encoding(encoderShouldEmitUTF8Identifier: true)
Encoding.Default (.NET Core/.NET 5以降)
UTF8Encoding(encoderShouldEmitUTF8Identifier: false)
UTF-16
Little Endian
Encoding.Unicode
UnicodeEncoding(bigEndian: false, byteOrderMark: true)
UnicodeEncoding(bigEndian: false, byteOrderMark: false)
UTF-16
Big Endian
Encoding.BigEndianUnicode
UnicodeEncoding(bigEndian: true, byteOrderMark: true)
UnicodeEncoding(bigEndian: true, byteOrderMark: false)
UTF-32
Little Endian
Encoding.UTF32
UTF32Encoding(bigEndian: false, byteOrderMark: true)
UTF32Encoding(bigEndian: false, byteOrderMark: false)
UTF-32
Big Endian
UTF32Encoding(bigEndian: true, byteOrderMark: true) UTF32Encoding(bigEndian: true, byteOrderMark: false)

EncodingとBOMのあり/なし、StreamWriter等におけるBOM書き込みの動作

Encodingクラスおよび派生クラスでは、そのエンコーディングでのBOMを表すバイト列(以降preambleと表記)を取得することができる。 preambleは、Preambleプロパティ(.NET Standard 2.1/.NET Core 2.1以降)あるいはGetPreambleメソッド(.NET Framework 1.1以降)から取得できる。 これらはどちらも同じ内容のバイト列を返す。

UTF-8を扱うクラスUTF8Encodingにおいては、コンストラクタの引数encoderShouldEmitUTF8Identifiertrueを指定すると、preambleとしてUTF-8のBOMが設定される。 逆にfalseを指定するとpreambleとして空のバイト列が設定される。

他方、StreamWriterなど書き込みを行うクラスでは、Encodingからpreambleを取得して出力する。 そのため、例えばStreamWriterがBOMを出力するかどうかは、StreamWriterに与えられたEncodingが何らかのpreambleを返すかどうかによって決まる。

この動作をより具体的に述べると次のようになる。 StreamWriterは、ストリームへの最初の書き込みを行う前にまずEncodingからpreambleを取得し、その内容をストリームへ書き込む。 このとき、preambleに何らかのバイト列が設定されていれば、それがBOMとして書き込まれることになる。 preambleとして空の配列が設定されている場合は、当然ストリームには何も書き込まれないため、結果としてBOMは書き込まれないことになる。

テキスト ファイルのエンコード方式を調べる方法はありますか。

(中略)

StreamWriter クラスも Encoding::GetPreamble() メソッドを呼び出し、テキスト ファイルの先頭にこのバイト列を書き込みます。これは優れた機能です。ユーザーがテキスト ファイルのエンコード方式をはっきり特定できるからです。ただし、弊社の多くの開発者は C 言語の知識を持っているため、テキスト ファイルの先頭に UTF-8 の Unicode BOM があると混乱してしまいました。また、Unicode に対応していないエディタ (vi、以前のバージョンの Emacs など) では扱いにくいこともあります。このため、StreamWriter クラスで既定で使用される UTF8Encoding では、GetPreamble メソッドから空のバイト列が返されます。UTF-8 ファイルに Unicode BOM を書き込むには、コード内で Encoding.UTF8 を明示的に指定します。

.NET Framework Developer Center:System.System.IO に関する FAQ | Microsoft Docs

UTF8Encodingと同様に、UTF-16を扱うUnicodeEncodingクラス・UTF-32を扱うUTF32Encodingクラスの場合も、コンストラクタの引数byteOrderMarkでpreambleとして設定するバイト列をBOMとするか空のバイト列とするかを指定することができる。 BOMが定義されないShift_JISなどのEncodingでは、preambleは空のバイト列となる。

StreamWriter以外のクラスやメソッドを使った書き込みの場合も、Encodingのpreambleを書き込む動作になっていれば、それによってBOM出力のありなしが決まる。 (§.File.WriteAllText§.System.Xml名前空間)

一方、BinaryWriterはEncodingを指定できるものの、preambleは無視される(書き込まれない)ため、常にBOMなしで出力される。 (§.BinaryWriter)

なお、Encoding.GetBytesメソッドなどの文字列からバイト列等に変換するメソッドでは、preambleを含まない結果を返す。 (常にBOMなしとなる)

EncodingがBOMあり/なしかどうか知る方法

あるEncodingがBOMを出力するかどうかは、Preambleプロパティ・GetPreambleメソッドが返すpreambleの内容・長さによって判断できる。 そのため、これを調べることによって、EncodingやStreamWriterがBOMを出力するかどうか事前に知ることができる。

EncodingがBOMを出力するかどうか調べる
using System;
using System.Text;

class Sample {
  static void Main()
  {
    var encodings = new[] {
      Encoding.UTF8,            // UTF-8 BOM
      Encoding.Default,         // UTF-8 non-BOM
      new UTF8Encoding(true),   // UTF-8 BOM
      new UTF8Encoding(false),  // UTF-8 non-BOM
      Encoding.Unicode,                   // UTF-16 Little Endian BOM
      new UnicodeEncoding(false, false),  // UTF-16 Little Endian non-BOM
      shift_jis,                // Shift_JIS
    };

    foreach (var encoding in encodings) {
      var preamble = encoding.GetPreamble();

      Console.WriteLine(
        "{0,12} {1,3} <{2}>",
        encoding.WebName, // encodingのweb nameを表示
        preamble.Length == 0 ? string.Empty : "BOM", // preambleの長さからBOMのありなしを判定
        BitConverter.ToString(preamble) // preambleの内容を表示
      );
    }
  }

  static readonly Encoding shift_jis =
#if NETFRAMEWORK
    Encoding.GetEncoding("shift_jis");
#else
    // `dotnet add sample.csproj package System.Text.Encoding.CodePages`
    CodePagesEncodingProvider.Instance.GetEncoding("shift_jis");
#endif
}
EncodingがBOMを出力するかどうか調べる
Option Infer On

Imports System
Imports System.IO
Imports System.Text

Class Sample
  Shared Sub Main()
    Dim encodings = New Encoding() {
      Encoding.UTF8,            ' UTF-8 BOM
      Encoding.Default,         ' UTF-8 non-BOM
      New UTF8Encoding(True),   ' UTF-8 BOM
      New UTF8Encoding(False),  ' UTF-8 non-BOM
      Encoding.Unicode,                   ' UTF-16 Little Endian BOM
      New UnicodeEncoding(False, False),  ' UTF-16 Little Endian non-BOM
      shift_jis                 ' Shift_JIS
    }

    For Each encoding In encodings
      Dim preamble = encoding.GetPreamble()

      Console.WriteLine(
        "{0,12} {1,3} <{2}>",
        encoding.WebName, ' encodingのweb nameを表示
        If(preamble.Length = 0, String.Empty, "BOM"), ' preambleの長さからBOMのありなしを判定
        BitConverter.ToString(preamble) ' preambleの内容を表示
      )
    Next
  End Sub

#If NETFRAMEWORK Then
    Shared ReadOnly shift_jis As Encoding = Encoding.GetEncoding("shift_jis")
#Else
    ' `dotnet add sample.vbproj package System.Text.Encoding.CodePages`
    Shared ReadOnly shift_jis As Encoding = CodePagesEncodingProvider.Instance.GetEncoding("shift_jis")
#End If
End Class
実行結果
       utf-8 BOM <EF-BB-BF>
       utf-8     <>
       utf-8 BOM <EF-BB-BF>
       utf-8     <>
      utf-16 BOM <FF-FE>
      utf-16     <>
   shift_jis     <>

StreamReaderにおける文字コードの自動判別とBOMあり/なしの判別

StreamReaderでは、コンストラクタの引数detectEncodingFromByteOrderMarkstrueを指定すると、ストリーム先頭のBOMから文字コードの自動判別を行うようになる。

自動判別された文字コードはCurrentEncodingプロパティに反映されるが、BOMがない場合を含め自動判別できなかった場合はBOMありのUTF-8が設定される。 またPeek/Readなどのメソッドは、BOMを除いた上で読み込んだ内容を返す。 このため、StreamReaderからは読み込んだストリームがBOMあり/なしかどうかを判別することができない

StreamReader.CurrentEncodingプロパティからストリームのpreambleの取得を試行する(すべてBOMありとなる)
using System;
using System.IO;
using System.Text;

class Sample {
  static void Main()
  {
    var encodings = new Encoding[] {
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: true),
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
      new UnicodeEncoding(bigEndian: true, byteOrderMark: true),
      new UnicodeEncoding(bigEndian: true, byteOrderMark: false),
      new UnicodeEncoding(bigEndian: false, byteOrderMark: true),
      new UnicodeEncoding(bigEndian: false, byteOrderMark: false),
      new UTF32Encoding(bigEndian: true, byteOrderMark: true),
      new UTF32Encoding(bigEndian: true, byteOrderMark: false),
      new UTF32Encoding(bigEndian: false, byteOrderMark: true),
      new UTF32Encoding(bigEndian: false, byteOrderMark: false),
      shift_jis,
    };
    const string text = "日本語";

    foreach (var encoding in encodings) {
      // 各種Encoding+BOMあり/なしでMemoryStreamに書き込む
      Console.Write($"{encoding.WebName,12} {(encoding.Preamble.Length == 0 ? string.Empty : "BOM"),3} => ");

      using var stream = new MemoryStream();

      using (var writer = new StreamWriter(stream, encoding, leaveOpen: true)) {
        writer.Write(text);
      }

      stream.Position = 0L;

      // BOMによるEncodingの自動判別を有効にしてMemoryStreamから読み込む
      using (var reader = new StreamReader(stream, detectEncodingFromByteOrderMarks: true)) {
        reader.Peek(); // 自動判別されたEncodingを参照するには、まずPeek/Readを行う必要がある

        // 自動判別されたEncodingを参照する
        var e = reader.CurrentEncoding;

        Console.WriteLine($"{e.WebName,12} {(e.Preamble.Length == 0 ? string.Empty : "BOM"),3}");
      }
    }
  }

  static readonly Encoding shift_jis =
#if NETFRAMEWORK
    Encoding.GetEncoding("shift_jis");
#else
    // `dotnet add sample.csproj package System.Text.Encoding.CodePages`
    CodePagesEncodingProvider.Instance.GetEncoding("shift_jis");
#endif
}
StreamReader.CurrentEncodingプロパティからストリームのpreambleの取得を試行する(すべてBOMありとなる)
Option Infer On

Imports System
Imports System.IO
Imports System.Text

Class Sample
  Shared Sub Main()
    Dim encodings = New Encoding() {
      New UTF8Encoding(encoderShouldEmitUTF8Identifier := True),
      New UTF8Encoding(encoderShouldEmitUTF8Identifier := False),
      New UnicodeEncoding(bigEndian := True, byteOrderMark := True),
      New UnicodeEncoding(bigEndian := True, byteOrderMark := False),
      New UnicodeEncoding(bigEndian := False, byteOrderMark := True),
      New UnicodeEncoding(bigEndian := False, byteOrderMark := False),
      New UTF32Encoding(bigEndian := True, byteOrderMark := True),
      New UTF32Encoding(bigEndian := True, byteOrderMark := False),
      New UTF32Encoding(bigEndian := False, byteOrderMark := True),
      New UTF32Encoding(bigEndian := False, byteOrderMark := False),
      shift_jis
    }

    Const text As String = "日本語"

    For Each encoding In encodings
      ' 各種Encoding+BOMあり/なしでMemoryStreamに書き込む
      Console.Write($"{encoding.WebName,12} {If(encoding.Preamble.Length = 0, String.Empty, "BOM"),3} => ")

      Using stream As New MemoryStream()
        Using writer As New StreamWriter(stream, encoding, leaveOpen := True)
          writer.Write(text)
        End Using

        stream.Position = 0L

        ' BOMによるEncodingの自動判別を有効にしてMemoryStreamから読み込む
        Using reader As New StreamReader(stream, detectEncodingFromByteOrderMarks := True)
          reader.Peek() ' 自動判別されたEncodingを参照するには、まずPeek/Readを行う必要がある

          ' 自動判別されたEncodingを参照する
          Dim e = reader.CurrentEncoding

          Console.WriteLine($"{e.WebName,12} {If(e.Preamble.Length = 0, String.Empty, "BOM"),3}")
        End Using
      End Using
    Next
  End Sub

#If NETFRAMEWORK Then
    Shared ReadOnly shift_jis As Encoding = Encoding.GetEncoding("shift_jis")
#Else
    ' `dotnet add sample.vbproj package System.Text.Encoding.CodePages`
    Shared ReadOnly shift_jis As Encoding = CodePagesEncodingProvider.Instance.GetEncoding("shift_jis")
#End If
End Class
実行結果
       utf-8 BOM =>        utf-8 BOM
       utf-8     =>        utf-8 BOM
    utf-16BE BOM =>     utf-16BE BOM
    utf-16BE     =>        utf-8 BOM
      utf-16 BOM =>       utf-16 BOM
      utf-16     =>        utf-8 BOM
    utf-32BE BOM =>     utf-32BE BOM
    utf-32BE     =>        utf-8 BOM
      utf-32 BOM =>       utf-32 BOM
      utf-32     =>        utf-8 BOM
   shift_jis     =>        utf-8 BOM

StreamクラスでEncodingの自動判別とBOMを維持した読み込みを行う例については§.BOMによる自動判別とBOMの透過的読み込みを行うStreamを参照。

各クラスにおけるBOM出力の動作と制御

以下は書き込みを行うクラスにおけるBOM出力の動作と、BOMありなしを指定する方法について。

標準ストリーム

Consoleクラス

Consoleクラスでは、Console.OutputEncodingによって標準出力で使用するエンコーディングを変更することができる。

.NET Framework/.NET 5のConsoleクラスでは、Console.OutputEncodingに設定したEncodingのpreambleによらずBOMは出力されない。 またWindows上では、chcpコマンドでコンソールの文字コードをUTF-8にしてもBOMは出力されない

MonoのConsoleクラスでは、preambleに応じてBOMが出力される。 Monoでは環境変数LANG(ja_JP.UTF-8など)に従ってEncoding.Defaultに割り当てられるUTF8EncodingもBOMを出力する

Console.OutputEncodingとBOM出力の有無の違い
using System;
using System.IO;
using System.Text;

class Sample {
  static void Main()
  {
    const string text = "日本語";

    foreach (var e in new Encoding[] {
      // Console.OutputEncodingに指定する値を変えて標準出力に書き込む
      null, // 変更しない
      Encoding.UTF8,
      Encoding.Default, // ⚠.NET FrameworkのEncoding.DefaultはUTF-8ではなくShift_JIS等のANSIコードページとなる
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: true),
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
    }) {
      if (e != null)
        Console.OutputEncoding = e;

      Console.WriteLine(text);
      Console.Out.Flush();
    }
  }
}
実行結果
>chcp
現在のコード ページ: 932

>dotnet run > out.txt && certutil -encodehex out.txt out.hex.txt && type out.hex.txt

Input Length = 52
Output Length = 284
CertUtil: -encodehex command completed successfully.
0000    93 fa 96 7b 8c ea 0d 0a  e6 97 a5 e6 9c ac e8 aa   ...{............
0010    9e 0d 0a e6 97 a5 e6 9c  ac e8 aa 9e 0d 0a e6 97   ................
0020    a5 e6 9c ac e8 aa 9e 0d  0a e6 97 a5 e6 9c ac e8   ................
0030    aa 9e 0d 0a

>chcp 65001
Active code page: 65001

>dotnet run > out.txt && certutil -encodehex out.txt out.hex.txt && type out.hex.txt

Input Length = 55
Output Length = 287
CertUtil: -encodehex command completed successfully.
0000    e6 97 a5 e6 9c ac e8 aa  9e 0d 0a e6 97 a5 e6 9c   ................
0010    ac e8 aa 9e 0d 0a e6 97  a5 e6 9c ac e8 aa 9e 0d   ................
0020    0a e6 97 a5 e6 9c ac e8  aa 9e 0d 0a e6 97 a5 e6   ................
0030    9c ac e8 aa 9e 0d 0a                               .......

⚠区切り(改行文字)を強調してある

実行結果
>chcp
現在のコード ページ: 932

>csc test.cs && test.exe > out.txt && certutil -encodehex out.txt out.hex.txt && type out.hex.txt

Input Length = 49
Output Length = 281
CertUtil: -encodehex command completed successfully.
0000    93 fa 96 7b 8c ea 0d 0a  e6 97 a5 e6 9c ac e8 aa   ...{............
0010    9e 0d 0a 93 fa 96 7b 8c  ea 0d 0a e6 97 a5 e6 9c   ......{.........
0020    ac e8 aa 9e 0d 0a e6 97  a5 e6 9c ac e8 aa 9e 0d   ................
0030    0a                                                 .

>chcp 65001
Active code page: 65001

>csc test.cs && test.exe > out.txt && certutil -encodehex out.txt out.hex.txt && type out.hex.txt

Input Length = 52
Output Length = 284
CertUtil: -encodehex command completed successfully.
0000    e6 97 a5 e6 9c ac e8 aa  9e 0d 0a e6 97 a5 e6 9c   ................
0010    ac e8 aa 9e 0d 0a 93 fa  96 7b 8c ea 0d 0a e6 97   .........{......
0020    a5 e6 9c ac e8 aa 9e 0d  0a e6 97 a5 e6 9c ac e8   ................
0030    aa 9e 0d 0a                                        ....

⚠区切り(改行文字)を強調してある

実行結果
$echo $LANG
ja_JP.UTF-8

$dotnet run | od -tx1
0000000 e6 97 a5 e6 9c ac e8 aa 9e 0a e6 97 a5 e6 9c ac
0000020 e8 aa 9e 0a e6 97 a5 e6 9c ac e8 aa 9e 0a e6 97
0000040 a5 e6 9c ac e8 aa 9e 0a e6 97 a5 e6 9c ac e8 aa
0000060 9e 0a
0000062

⚠区切り(改行文字)を強調してある

実行結果
$echo $LANG
ja_JP.UTF-8

$mcs test.cs && mono test.exe | od -tx1
0000000 e6 97 a5 e6 9c ac e8 aa 9e 0a ef bb bf e6 97 a5
0000020 e6 9c ac e8 aa 9e 0a ef bb bf e6 97 a5 e6 9c ac
0000040 e8 aa 9e 0a ef bb bf e6 97 a5 e6 9c ac e8 aa 9e
0000060 0a e6 97 a5 e6 9c ac e8 aa 9e 0a
0000073

⚠区切り(改行文字)とBOMを強調してある


Console.SetOutメソッドで標準出力をStreamWriterにリダイレクトする場合は、StreamWriter単体の場合と同様にBOMが出力される。

標準出力のリダイレクト先として指定したStreamWriterのEncodingとBOM出力の有無の違い 
using System;
using System.IO;
using System.Text;

const string text = "日本語";
var defaultStdOut = Console.Out;

foreach (var createStreamWriter in new Func<Stream, StreamWriter>[] {
  // 引数encodingに指定する値を変えてStreamWriterを作成する
  s => new(s), // encodingの指定なし
  s => new(s, encoding: Encoding.UTF8),
  s => new(s, encoding: Encoding.Default), // ⚠.NET FrameworkのEncoding.DefaultはUTF-8ではなくShift_JIS等のANSIコードページとなる
  s => new(s, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)),
  s => new(s, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)),
}) {
  using (var stdout = new MemoryStream()) {
    // StreamWriterを作成し、標準出力のリダイレクト先として設定する
    var writer = createStreamWriter(stdout);

    writer.AutoFlush = true;

    Console.SetOut(writer);

    // リダイレクトした標準出力に書き込む
    Console.Write(text);

    // 標準出力に書き込まれたバイト列を16進形式で表示
    defaultStdOut.WriteLine(BitConverter.ToString(stdout.ToArray()));
  }
}
実行結果
E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E

標準エラーも標準出力と同様の動作になると思われる。 (実際の動作は未調査)

Processクラス

Processクラスでは、起動したプロセスの標準入力をリダイレクトして書き込む際に使用するエンコーディングをProcessStartInfo.StandardInputEncodingで変更することができる。

.NET 5およびMonoのProcessクラスでは、StreamWriterの場合と同様にEncodingに設定されているpreambleに応じてBOMが出力される。 Monoでは環境変数LANG(ja_JP.UTF-8など)に従ってEncoding.Defaultに割り当てられるUTF8EncodingもBOMを出力する

.NET FrameworkではProcessStartInfo.StandardInputEncodingは定義されないため、標準入力への書き込みでは常にコンソールの文字コードが用いられる。 chcpコマンドでコンソールの文字コードをUTF-8にすると、BOMが出力される

ProcessStartInfo.StandardInputEncodingとBOM出力の有無の違い
using System;
using System.Diagnostics;
using System.IO;
using System.Text;

class Sample {
  static void Main()
  {
    if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CHILD")))
      ParentMain();
    else
      ChildMain();
  }

  static void ParentMain()
  {
    foreach (var e in new Encoding[] {
      null,
#if !NETFRAMEWORK
      Encoding.UTF8,
      Encoding.Default, // ⚠.NET FrameworkのEncoding.DefaultはUTF-8ではなくShift_JIS等のANSIコードページとなる
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: true),
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
#endif
    }) {
      // StandardInputEncodingに指定するEncodingを変えてProcessStartInfoを作成する
      // (現在のプロセスと同じ実行可能ファイルを子プロセスとして起動し、標準入力に書き込む)
      var psi =
#if MONO
      new ProcessStartInfo("mono", System.Reflection.Assembly.GetExecutingAssembly().Location)
#else
      new ProcessStartInfo(Process.GetCurrentProcess().MainModule.FileName)
#endif
      {
        EnvironmentVariables = { {"CHILD", "1"} },
        UseShellExecute = false,
        RedirectStandardInput = true,
#if !NETFRAMEWORK
        StandardInputEncoding = e,
#endif
      };

      const string text = "日本語";

      using (var child = Process.Start(psi)) {
        child.StandardInput.Write(text);
        child.StandardInput.Close();
        child.WaitForExit();
      }
    }
  }

  static void ChildMain()
  {
    // 標準入力に書き込まれたバイト列を16進形式で表示
    var reader = new BinaryReader(Console.OpenStandardInput());

    Console.WriteLine(BitConverter.ToString(reader.ReadBytes(32)));
  }
}
実行結果
>chcp
現在のコード ページ: 932

>dotnet run

93-FA-96-7B-8C-EA
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E

>chcp 65001
Active code page: 65001

>dotnet run

E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E
実行結果
>chcp
現在のコード ページ: 932

>csc /nologo /d:NETFRAMEWORK test.cs && test.exe
93-FA-96-7B-8C-EA

>chcp 65001
Active code page: 65001

>csc /nologo /d:NETFRAMEWORK test.cs && test.exe
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
実行結果
$echo $LANG
ja_JP.UTF-8

$dotnet run
E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E
実行結果
$echo $LANG
ja_JP.UTF-8

$mcs -d:MONO test.cs && mono test.exe
E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E

System.IO名前空間

StreamWriter (追記モード)

コンストラクタの引数appendtrueを指定して追記モードで書き込みを行う場合、ファイルが存在しない場合(新規作成となる場合)はEncodingのpreambleに応じてBOMが出力される。 一方、追記となる場合はpreambleによらずBOMの追記は行われない

上書きモード(append = false)でのStreamWriterの動作は前述のとおり

追記モードのStreamWriterに指定するEncodingとBOM出力の有無の違い 
using System;
using System.IO;
using System.Text;

const string path = "out.txt";
const string text = "日本語";

foreach (var createStreamWriter in new Func<StreamWriter>[] {
  // 引数encodingに指定する値を変えて、追記モード(append: true)のStreamWriterを作成する
  () => new(path, append: true), // encodingの指定なし
  () => new(path, append: true, encoding: Encoding.UTF8),
  () => new(path, append: true, encoding: Encoding.Default), // ⚠.NET FrameworkのEncoding.DefaultはUTF-8ではなくShift_JIS等のANSIコードページとなる
  () => new(path, append: true, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)),
  () => new(path, append: true, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)),
}) {
  if (File.Exists(path))
    File.Delete(path); // ファイルを削除しておく

  // 新規に作成したファイルへの書き込みとなる場合
  using (var writer = createStreamWriter()) {
    writer.Write(text);
  }

  // StreamWriterが出力したバイト列を16進形式で表示
  Console.Write("{0,-40} => ", BitConverter.ToString(File.ReadAllBytes(path)));

  // 既存のファイルへの追記となる場合
  using (var writer = createStreamWriter()) {
    writer.Write(text);
  }

  // StreamWriterが出力したバイト列を16進形式で表示
  Console.WriteLine(BitConverter.ToString(File.ReadAllBytes(path)));
}
実行結果
E6-97-A5-E6-9C-AC-E8-AA-9E               => E6-97-A5-E6-9C-AC-E8-AA-9E-E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E      => EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E               => E6-97-A5-E6-9C-AC-E8-AA-9E-E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E      => EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E               => E6-97-A5-E6-9C-AC-E8-AA-9E-E6-97-A5-E6-9C-AC-E8-AA-9E

File.WriteAllText

File.WriteAllTextなどのFileクラスの書き込みメソッドでは、StreamWriterと同様の動作でBOMが出力される。

File.WriteAllTextメソッドに指定するEncodingとBOM出力の有無の違い 
using System;
using System.IO;
using System.Text;

const string contents = "日本語";
const string path = "out.txt";

foreach (var writeAllText in new Action[] {
  // 引数encodingに指定する値を変えてFile.WriteAllTextを呼び出す
  () => File.WriteAllText(path, contents), // encodingの指定なし
  () => File.WriteAllText(path, contents, encoding: Encoding.UTF8),
  () => File.WriteAllText(path, contents, encoding: Encoding.Default), // ⚠.NET FrameworkのEncoding.DefaultはUTF-8ではなくShift_JIS等のANSIコードページとなる
  () => File.WriteAllText(path, contents, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)),
  () => File.WriteAllText(path, contents, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)),
}) {
  writeAllText();

  // File.WriteAllTextが出力したバイト列を16進形式で表示
  Console.WriteLine(BitConverter.ToString(File.ReadAllBytes(path)));
}
実行結果
E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E

File.AppendAllText

File.AppendAllTextなど、Fileクラスの追記による書き込みを行うメソッドでは、追記モードのStreamWriterと同様の動作となる。

File.AppendAllTextメソッドに指定するEncodingとBOM出力の有無の違い 
using System;
using System.IO;
using System.Text;

const string contents = "日本語";
const string path = "out.txt";

foreach (var appendAllText in new Action[] {
  // 引数encodingに指定する値を変えてFile.AppendAllTextを呼び出す
  () => File.AppendAllText(path, contents), // encodingの指定なし
  () => File.AppendAllText(path, contents, encoding: Encoding.UTF8),
  () => File.AppendAllText(path, contents, encoding: Encoding.Default), // ⚠.NET FrameworkのEncoding.DefaultはUTF-8ではなくShift_JIS等のANSIコードページとなる
  () => File.AppendAllText(path, contents, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)),
  () => File.AppendAllText(path, contents, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)),
}) {
  if (File.Exists(path))
    File.Delete(path); // ファイルを削除しておく

  appendAllText(); // 新規に作成したファイルへの書き込みとなる場合

  // File.AppendAllTextが出力したバイト列を16進形式で表示
  Console.Write("{0,-40} => ", BitConverter.ToString(File.ReadAllBytes(path)));

  appendAllText(); // 既存のファイルへの追記となる場合

  // File.AppendAllTextが出力したバイト列を16進形式で表示
  Console.WriteLine(BitConverter.ToString(File.ReadAllBytes(path)));
}
実行結果
E6-97-A5-E6-9C-AC-E8-AA-9E               => E6-97-A5-E6-9C-AC-E8-AA-9E-E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E      => EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E               => E6-97-A5-E6-9C-AC-E8-AA-9E-E6-97-A5-E6-9C-AC-E8-AA-9E
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E      => EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E-E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E               => E6-97-A5-E6-9C-AC-E8-AA-9E-E6-97-A5-E6-9C-AC-E8-AA-9E

BinaryWriter

BinaryWriterクラスでは、Writeメソッドで文字列を書き込むことができる。 また、このときに使用するEncodingをコンストラクタの引数encodingで指定することができる。

ただし、BinaryWriterはpreambleの内容に関わらずBOMを出力しない。 そのためBinaryWriterでBOMを出力させたい場合は、preambleを取得して明示的に書き込む必要がある。

なお、BinaryWriter.Writeメソッドでは文字列のバイト長を前置した上で書き込む動作となっている点に注意。

各Encodingコンストラクタに指定する値とGetPreamble等のメソッドの動作、書き込まれる内容の違いを調べる
using System;
using System.IO;
using System.Text;

const string text = "日本語";

// 比較のため文字列のバイト表現を取得して表示
Console.WriteLine("{0} ('{1}')", BitConverter.ToString(Encoding.UTF8.GetBytes(text)), text);
Console.WriteLine();

foreach (var createBinaryWriter in new Func<Stream, BinaryWriter>[] {
  // 引数encodingに指定する値を変えてBinaryWriterを作成する
  s => new(s), // encodingの指定なし
  s => new(s, encoding: Encoding.UTF8),
  s => new(s, encoding: Encoding.Default), // ⚠.NET FrameworkのEncoding.DefaultはUTF-8ではなくShift_JIS等のANSIコードページとなる
  s => new(s, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)),
  s => new(s, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)),
}) {
  using var stream = new MemoryStream();

  using (var writer = createBinaryWriter(stream)) {
    writer.Write(text);
  }

  // BinaryWriterが出力したバイト列を16進形式で表示
  Console.WriteLine(BitConverter.ToString(stream.ToArray()));
}
実行結果
E6-97-A5-E6-9C-AC-E8-AA-9E ('日本語')

09-E6-97-A5-E6-9C-AC-E8-AA-9E
09-E6-97-A5-E6-9C-AC-E8-AA-9E
09-E6-97-A5-E6-9C-AC-E8-AA-9E
09-E6-97-A5-E6-9C-AC-E8-AA-9E
09-E6-97-A5-E6-9C-AC-E8-AA-9E

System.Xml名前空間

XmlWriter

XmlWriter.Createメソッドで作成したXmlWriterを使用する場合、BOMが出力されるかどうかはXmlWriterSettings.Encodingプロパティに設定するEncoding次第となる。 デフォルトではEncoding.UTF8が設定されているため、特に指定しなかった場合はBOMが出力される

したがって、BOMを出力したくない場合は、BOMを出力しないEncodingを明示的にXmlWriterSettings.Encodingに指定する必要がある。

XmlWriterSettings.Encodingプロパティに指定するEncodingと、XmlWriterのBOM出力の有無の違い 
using System;
using System.IO;
using System.Text;
using System.Xml;

const string path = "out.xml";

var doc = new XmlDocument();

doc.AppendChild(doc.CreateElement("doc"));

foreach (var createWriterSettings in new Func<XmlWriterSettings>[] {
  // Encodingプロパティに指定する値を変えてXmlWriterSettingsを作成する
  () => new(), // Encodingの指定なし (デフォルトではEncoding.UTF8が設定されている)
  () => new() { Encoding = Encoding.UTF8 },
  () => new() { Encoding = Encoding.Default }, // ⚠.NET FrameworkのEncoding.DefaultはUTF-8ではなくShift_JIS等のANSIコードページとなる
  () => new() { Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true) },
  () => new() { Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false) },
}) {
  using (var writer = XmlWriter.Create(path, createWriterSettings())) {
    doc.Save(writer);
  }

  // XmlWriterが出力した内容を文字列・16進形式で表示
  Console.WriteLine(File.ReadAllText(path));
  Console.WriteLine(BitConverter.ToString(File.ReadAllBytes(path)));
  Console.WriteLine();
}
実行結果
<?xml version="1.0" encoding="utf-8"?><doc />
EF-BB-BF-3C-3F-78-6D-6C-20-76-65-72-73-69-6F-6E-3D-22-31-2E-30-22-20-65-6E-63-6F-64-69-6E-67-3D-22-75-74-66-2D-38-22-3F-3E-3C-64-6F-63-20-2F-3E

<?xml version="1.0" encoding="utf-8"?><doc />
EF-BB-BF-3C-3F-78-6D-6C-20-76-65-72-73-69-6F-6E-3D-22-31-2E-30-22-20-65-6E-63-6F-64-69-6E-67-3D-22-75-74-66-2D-38-22-3F-3E-3C-64-6F-63-20-2F-3E

<?xml version="1.0" encoding="utf-8"?><doc />
3C-3F-78-6D-6C-20-76-65-72-73-69-6F-6E-3D-22-31-2E-30-22-20-65-6E-63-6F-64-69-6E-67-3D-22-75-74-66-2D-38-22-3F-3E-3C-64-6F-63-20-2F-3E

<?xml version="1.0" encoding="utf-8"?><doc />
EF-BB-BF-3C-3F-78-6D-6C-20-76-65-72-73-69-6F-6E-3D-22-31-2E-30-22-20-65-6E-63-6F-64-69-6E-67-3D-22-75-74-66-2D-38-22-3F-3E-3C-64-6F-63-20-2F-3E

<?xml version="1.0" encoding="utf-8"?><doc />
3C-3F-78-6D-6C-20-76-65-72-73-69-6F-6E-3D-22-31-2E-30-22-20-65-6E-63-6F-64-69-6E-67-3D-22-75-74-66-2D-38-22-3F-3E-3C-64-6F-63-20-2F-3E

XmlDocument

XmlWriterを使わずXmlDocument.SaveメソッドでXML文書を出力する場合は次のようになる。

XML宣言(XmlDeclaration)のencoding属性に"utf-8"などを指定した場合は、BOM付きのUTF-8で出力される。 一方nullstring.Emptyを指定した場合はBOMなしのUTF-8で出力されるが、encoding属性は省略される。

したがって、BOMを出力せず、かつencoding属性付きのXML宣言を出力したい場合にはXmlWriterを使う必要がある。

XML宣言のencoding属性に指定する値と、XmlDocument.SaveメソッドのBOM出力の有無の違い 
using System;
using System.IO;
using System.Xml;

const string path = "out.xml";

foreach (var createXmlDeclaration in new Func<XmlDocument, XmlDeclaration>[] {
  // encoding属性に指定する値を変えてXML宣言(XmlDeclaration)を作成する
  d => d.CreateXmlDeclaration(version: "1.0", encoding: "utf-8", standalone: null),
  d => d.CreateXmlDeclaration(version: "1.0", encoding: null, standalone: null),
  d => d.CreateXmlDeclaration(version: "1.0", encoding: string.Empty, standalone: null),
}) {
  var doc = new XmlDocument();

  doc.AppendChild(createXmlDeclaration(doc));
  doc.AppendChild(doc.CreateElement("doc"));

  doc.Save(path);

  // XmlDocument.Saveが出力した内容を文字列・16進形式で表示
  Console.WriteLine(File.ReadAllText(path));
  Console.WriteLine(BitConverter.ToString(File.ReadAllBytes(path)));
  Console.WriteLine();
}
実行結果
<?xml version="1.0" encoding="utf-8"?>
<doc />
EF-BB-BF-3C-3F-78-6D-6C-20-76-65-72-73-69-6F-6E-3D-22-31-2E-30-22-20-65-6E-63-6F-64-69-6E-67-3D-22-75-74-66-2D-38-22-3F-3E-0A-3C-64-6F-63-20-2F-3E

<?xml version="1.0"?>
<doc />
3C-3F-78-6D-6C-20-76-65-72-73-69-6F-6E-3D-22-31-2E-30-22-3F-3E-0A-3C-64-6F-63-20-2F-3E

<?xml version="1.0"?>
<doc />
3C-3F-78-6D-6C-20-76-65-72-73-69-6F-6E-3D-22-31-2E-30-22-3F-3E-0A-3C-64-6F-63-20-2F-3E

XDocument (System.Xml.Linq)

XmlWriterを使わずXDocument.SaveメソッドでXML文書を出力する場合は次のようになる。

XML宣言(XDeclaration)のencoding属性に"utf-8"/null/string.Emptyのいずれを指定した場合でもBOM付きのUTF-8で出力される。 したがって、BOMを出力したくない場合には、BOMを出力しないEncodingを使用するXmlWriterを使う必要がある。

XML宣言のencoding属性に指定する値と、XDocument.SaveメソッドのBOM出力の有無の違い 
using System;
using System.IO;
using System.Xml.Linq;

const string path = "out.xml";

foreach (var createXDeclaration in new Func<XDeclaration>[] {
  // encoding属性に指定する値を変えてXML宣言(XDeclaration)を作成する
  () => new(version: "1.0", encoding: "utf-8", standalone: null),
  () => new(version: "1.0", encoding: null, standalone: null),
  () => new(version: "1.0", encoding: string.Empty, standalone: null),
}) {
  var doc = new XDocument(
    createXDeclaration(),
    new XElement("doc")
  );

  doc.Save(path);

  // XDocument.Saveが出力した内容を文字列・16進形式で表示
  Console.WriteLine(File.ReadAllText(path));
  Console.WriteLine(BitConverter.ToString(File.ReadAllBytes(path)));
  Console.WriteLine();
}
実行結果
<?xml version="1.0" encoding="utf-8"?>
<doc />
EF-BB-BF-3C-3F-78-6D-6C-20-76-65-72-73-69-6F-6E-3D-22-31-2E-30-22-20-65-6E-63-6F-64-69-6E-67-3D-22-75-74-66-2D-38-22-3F-3E-0A-3C-64-6F-63-20-2F-3E

<?xml version="1.0" encoding="utf-8"?>
<doc />
EF-BB-BF-3C-3F-78-6D-6C-20-76-65-72-73-69-6F-6E-3D-22-31-2E-30-22-20-65-6E-63-6F-64-69-6E-67-3D-22-75-74-66-2D-38-22-3F-3E-0A-3C-64-6F-63-20-2F-3E

<?xml version="1.0" encoding="utf-8"?>
<doc />
EF-BB-BF-3C-3F-78-6D-6C-20-76-65-72-73-69-6F-6E-3D-22-31-2E-30-22-20-65-6E-63-6F-64-69-6E-67-3D-22-75-74-66-2D-38-22-3F-3E-0A-3C-64-6F-63-20-2F-3E

JsonSerializer (System.Text.Json)

RFC 8259 - The JavaScript Object Notation (JSON) Data Interchange Format (8.1. Character Encoding)にて、JSONフォーマットでは(閉じたシステム内で使用する場合を除き)UTF-8を使用しなければならない(MUST)、ネットワーク転送される場合はBOMを付加してはならない(MUST NOT)とされている。

JsonSerializerクラスはこれに沿った動作となっていて、常にBOMなしのUTF-8で出力される。 オプション等でEncodingを明示的に指定することはできない。

JsonSerializerではBOMは出力されない 
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

var stream = new MemoryStream();

await JsonSerializer.SerializeAsync<string[]>(stream, new[] {"json"});

// JsonSerializerが出力した内容を文字列・16進形式で表示
Console.WriteLine(Encoding.UTF8.GetString(stream.ToArray()));
Console.WriteLine(BitConverter.ToString(stream.ToArray()));
実行結果
["json"]
5B-22-6A-73-6F-6E-22-5D

ファイルとして出力する際など、なんらかの理由でBOMを付加する必要がある場合は、次のように出力ストリームにBOMを書き込んでからJsonSerializerで書き込むことにより、BOMありのUTF-8として出力できる。

JsonSerializerを使ってBOM付きでファイルに出力する 
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

const string path = "out.json";

using (var stream = File.OpenWrite(path)) {
  stream.SetLength(0L); // 既存の内容を上書きする(truncate)

  stream.Write(Encoding.UTF8.Preamble); // UTF-8のBOMを書き込む

  await JsonSerializer.SerializeAsync<string[]>(stream, new[] {"json"}); // JsonSerializerでStreamに書き込む
}

// JsonSerializerが出力した内容を文字列・16進形式で表示
Console.WriteLine(File.ReadAllText(path));
Console.WriteLine(BitConverter.ToString(File.ReadAllBytes(path)));
実行結果
["json"]
EF-BB-BF-5B-22-6A-73-6F-6E-22-5D

BOMによる自動判別とBOMの透過的読み込みを行うStream

以下は、BOMからEncodingを自動判別し、BOMの透過的な読み込みを行うStreamラッパーBomTransparentStreamを実装した例。

与えられたStreamからBOMを読み込み、Encodingの自動判別を行うと同時に、コンストラクタでの指定に応じてBOMを維持/破棄した上で読み込むことができる。 StreamReaderとは異なり、自動判別できなかった場合はBOMなしのUTF-8として判別される。

簡易な実装かつ限定的なユースケースのみでの動作確認しかしていないため、使用方法によっては実装を修正する必要が出てくると思われる。 その他の制限・未実装事項等についてはコード中のコメントを参照。

BomTransparentStream 
using System;
using System.IO;
using System.Linq;
using System.Text;

/// <summary>与えられたStreamの内容を読み込み、BOMを維持/破棄した上で透過的に返すStream。 同時にBOMからEncodingを自動判別も行う。</summary>
class BomTransparentStream : Stream {
  private Stream baseStream;
  public Stream BaseStream => baseStream ?? throw new ObjectDisposedException(GetType().FullName);

  private readonly bool discardBom;

  public BomTransparentStream(string path, bool discardBom = true)
    : this(File.OpenRead(path), discardBom) {}

  public BomTransparentStream(Stream baseStream, bool discardBom = true)
  {
    this.baseStream = baseStream ?? throw new ArgumentNullException(nameof(baseStream));
    this.discardBom = discardBom;
  }

  public override void Close()
  {
    baseStream?.Close();
    baseStream = null;

    base.Close();
  }

  public override bool CanSeek => false; // シークはサポートしない
  public override bool CanRead => BaseStream.CanRead;
  public override bool CanWrite => false; // 書き込みはサポートしない
  public override bool CanTimeout => BaseStream.CanTimeout;
  public override long Position {
    get => BaseStream.Position; // 本来ならBOM分を差し引いた値を返すべき
    set => throw new NotSupportedException();
  }
  public override long Length => BaseStream.Length; // 本来ならBOM分を差し引いた値を返すべき
  public override void SetLength(long @value) => throw new NotSupportedException(); // 長さの設定はサポートしない
  public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
  public override void Flush() => throw new NotSupportedException();
  public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();

  /// <summary>BOMから自動判別されたEncoding</summary>
  /// <remarks>未判別の状態、また判別できなかった場合はEncoding.Defaultを返す</remarks>
  public Encoding DetectedEncoding { get; private set; } = Encoding.Default;

  /// <summary>まだBOMを読み込んでいない場合は、BOMの読み込みとEncodingの自動判別を行う</summary>
  public void ReadPreamble()
  {
    if (isPreamble)
      Read(Array.Empty<byte>(), 0, 0);
  }

  private bool isPreamble = true;
  private ReadOnlyMemory<byte> preamble = default;

  /// <summary>BaseStreamから読み込み、BOMを維持/破棄した結果を返す。 最初に呼び出された際は、BOMからEncodingの自動判別も行う。</summary>
  public override int Read(byte[] buffer, int offset, int count)
  {
    // 引数チェックは省略

    if (isPreamble) {
      isPreamble = false;

      // まだBOMを読み込んでいない場合は、BaseStreamの先頭4バイト分を読み込む
      Memory<byte> _preamble = new byte[4];

      BaseStream.Read(_preamble.Span); // 戻り値のチェックは省略、この実装ではStreamの内容が4バイト未満の場合は想定していない

      // Encodingの判別を行い、判別できた場合はBOMを除去した結果を取得する
      var (detectedEncoding, remainder) = DetectEncodingFromPreamble(_preamble);

      DetectedEncoding = detectedEncoding;

      // BOMを維持/破棄した内容をpreambleとして保持する
      preamble = discardBom ? remainder : _preamble;
    }

    var buf = buffer.AsMemory(offset, count);
    var readCount = 0;

    for (;;) {
      // preambleとして保持されている内容が空なら、BaseStreamから読み込み、そのまま返す
      if (preamble.IsEmpty)
        return readCount + BaseStream.Read(buf.Span);

      // preambleの内容をbufferにコピーする
      if (preamble.Length <= buf.Length) {
        var len = preamble.Length;

        preamble.CopyTo(buf);
        preamble = default; // コピー済みのため、空にする

        buf = buf.Slice(len);

        readCount += len;
      }
      else {
        var len = buf.Length;

        preamble.Slice(0, len).CopyTo(buf);
        preamble = preamble.Slice(len);

        return len;
      }
    }
  }

  private static (Encoding, ReadOnlyMemory<byte>) DetectEncodingFromPreamble(ReadOnlyMemory<byte> preamble)
  {
    // 与えられたpreambleからEncodingを判別する
    var e = encodings.FirstOrDefault(e => preamble.Span.StartsWith(e.Preamble)) ?? Encoding.Default;

    // 判別したEncodingと、BOMを差し引いた分を返す
    return (e, preamble.Slice(e.Preamble.Length));
  }

  // BOMの長い順にソート済みのEncoding
  private static readonly Encoding[] encodings = new[] {
    Encoding.UTF32,
    new UTF32Encoding(bigEndian: true, byteOrderMark: true),
    Encoding.UTF8,
    Encoding.Unicode,
    Encoding.BigEndianUnicode,
  };
}

以下は使用例。

使用例1: ファイルを読み込み、BOMを除去した上で上書きする
using System;
using System.IO;
using System.Text;

class Sample {
  static void Main()
  {
    var encodings = new Encoding[] {
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: true),
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
      new UnicodeEncoding(bigEndian: true, byteOrderMark: true),
      new UnicodeEncoding(bigEndian: true, byteOrderMark: false),
      new UnicodeEncoding(bigEndian: false, byteOrderMark: true),
      new UnicodeEncoding(bigEndian: false, byteOrderMark: false),
      new UTF32Encoding(bigEndian: true, byteOrderMark: true),
      new UTF32Encoding(bigEndian: true, byteOrderMark: false),
      new UTF32Encoding(bigEndian: false, byteOrderMark: true),
      new UTF32Encoding(bigEndian: false, byteOrderMark: false),
      shift_jis,
    };

    const string contents = "日本語";
    const string path = "out.txt";

    foreach (var encoding in encodings) {
      // 各種Encoding+BOMあり/なしでファイルに書き込む
      File.WriteAllText(path, contents, encoding);

      // 出力したファイル内容を16進形式で表示
      Console.Write("{0,-60} => ", BitConverter.ToString(File.ReadAllBytes(path)));

      using (var buffer = new MemoryStream()) {
        // 出力したファイルをBomTransparentStreamで読み込む
        using (var bomTransparentStream = new BomTransparentStream(path, discardBom: true /*BOMは除去する*/)) {
          bomTransparentStream.CopyTo(buffer); // 内容をbufferにコピー
        }

        using (var fileStream = File.OpenWrite(path)) {
          fileStream.SetLength(0L); // truncate

          buffer.Position = 0L;
          buffer.CopyTo(fileStream); // bufferの内容をコピー(ファイルに上書き)
        }
      }

      // 出力したファイル内容を16進形式で表示
      Console.WriteLine(BitConverter.ToString(File.ReadAllBytes(path)));
    }
  }

  static readonly Encoding shift_jis =
#if NETFRAMEWORK
    Encoding.GetEncoding("shift_jis");
#else
    // `dotnet add sample.csproj package System.Text.Encoding.CodePages`
    CodePagesEncodingProvider.Instance.GetEncoding("shift_jis");
#endif
}
実行結果
EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E                          => E6-97-A5-E6-9C-AC-E8-AA-9E
E6-97-A5-E6-9C-AC-E8-AA-9E                                   => E6-97-A5-E6-9C-AC-E8-AA-9E
FE-FF-65-E5-67-2C-8A-9E                                      => 65-E5-67-2C-8A-9E
65-E5-67-2C-8A-9E                                            => 65-E5-67-2C-8A-9E
FF-FE-E5-65-2C-67-9E-8A                                      => E5-65-2C-67-9E-8A
E5-65-2C-67-9E-8A                                            => E5-65-2C-67-9E-8A
00-00-FE-FF-00-00-65-E5-00-00-67-2C-00-00-8A-9E              => 00-00-65-E5-00-00-67-2C-00-00-8A-9E
00-00-65-E5-00-00-67-2C-00-00-8A-9E                          => 00-00-65-E5-00-00-67-2C-00-00-8A-9E
FF-FE-00-00-E5-65-00-00-2C-67-00-00-9E-8A-00-00              => E5-65-00-00-2C-67-00-00-9E-8A-00-00
E5-65-00-00-2C-67-00-00-9E-8A-00-00                          => E5-65-00-00-2C-67-00-00-9E-8A-00-00
93-FA-96-7B-8C-EA                                            => 93-FA-96-7B-8C-EA

使用例2: ファイルを読み込み、BOMからEncodingの自動判別を行い、BOMを維持したままBinaryReaderで読み込む
using System;
using System.IO;
using System.Text;

class Sample {
  static void Main()
  {
    var encodings = new Encoding[] {
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: true),
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
      new UnicodeEncoding(bigEndian: true, byteOrderMark: true),
      new UnicodeEncoding(bigEndian: true, byteOrderMark: false),
      new UnicodeEncoding(bigEndian: false, byteOrderMark: true),
      new UnicodeEncoding(bigEndian: false, byteOrderMark: false),
      new UTF32Encoding(bigEndian: true, byteOrderMark: true),
      new UTF32Encoding(bigEndian: true, byteOrderMark: false),
      new UTF32Encoding(bigEndian: false, byteOrderMark: true),
      new UTF32Encoding(bigEndian: false, byteOrderMark: false),
      shift_jis,
    };

    const string contents = "日本語";
    const string path = "out.txt";

    foreach (var encoding in encodings) {
      // 各種Encoding+BOMあり/なしでファイルに書き込む
      File.WriteAllText(path, contents, encoding);

      using (var bomTransparentStream = new BomTransparentStream(path, discardBom: false /*BOMは維持する*/)) {
        bomTransparentStream.ReadPreamble(); // Streamの先頭4バイトを読み込み、Encodingの自動判別を行う

        var e = bomTransparentStream.DetectedEncoding; // 自動判別されたEncodingを取得する

        Console.Write($"{e.WebName,12} {(e.Preamble.Length == 0 ? "   " : "BOM")} : ");

        // BinaryReaderでStreamの内容を読み込む
        var reader = new BinaryReader(bomTransparentStream);

        Console.WriteLine(BitConverter.ToString(reader.ReadBytes((int)bomTransparentStream.Length)));
      }
    }
  }

  static readonly Encoding shift_jis =
#if NETFRAMEWORK
    Encoding.GetEncoding("shift_jis");
#else
    // `dotnet add sample.csproj package System.Text.Encoding.CodePages`
    CodePagesEncodingProvider.Instance.GetEncoding("shift_jis");
#endif
}
実行結果
       utf-8 BOM : EF-BB-BF-E6-97-A5-E6-9C-AC-E8-AA-9E
       utf-8     : E6-97-A5-E6-9C-AC-E8-AA-9E
    utf-16BE BOM : FE-FF-65-E5-67-2C-8A-9E
       utf-8     : 65-E5-67-2C-8A-9E
      utf-16 BOM : FF-FE-E5-65-2C-67-9E-8A
       utf-8     : E5-65-2C-67-9E-8A
    utf-32BE BOM : 00-00-FE-FF-00-00-65-E5-00-00-67-2C-00-00-8A-9E
       utf-8     : 00-00-65-E5-00-00-67-2C-00-00-8A-9E
      utf-32 BOM : FF-FE-00-00-E5-65-00-00-2C-67-00-00-9E-8A-00-00
       utf-8     : E5-65-00-00-2C-67-00-00-9E-8A-00-00
       utf-8     : 93-FA-96-7B-8C-EA

先にEncodingの自動判別を行い、判別されたEncodingを使ってStreamReaderで読み込む
using System;
using System.IO;
using System.Text;

class Sample {
  static void Main()
  {
    var encodings = new Encoding[] {
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: true),
      new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
      new UnicodeEncoding(bigEndian: true, byteOrderMark: true),
      new UnicodeEncoding(bigEndian: true, byteOrderMark: false),
      new UnicodeEncoding(bigEndian: false, byteOrderMark: true),
      new UnicodeEncoding(bigEndian: false, byteOrderMark: false),
      new UTF32Encoding(bigEndian: true, byteOrderMark: true),
      new UTF32Encoding(bigEndian: true, byteOrderMark: false),
      new UTF32Encoding(bigEndian: false, byteOrderMark: true),
      new UTF32Encoding(bigEndian: false, byteOrderMark: false),
      shift_jis,
    };
    const string text = "日本語";

    foreach (var encoding in encodings) {
      // 各種Encoding+BOMあり/なしでMemoryStreamに書き込む
      Console.Write($"{encoding.WebName,12} {(encoding.Preamble.Length == 0 ? "   " : "BOM")} => ");

      using var stream = new MemoryStream();

      using (var writer = new StreamWriter(stream, encoding, leaveOpen: true)) {
        writer.Write(text);
      }

      stream.Position = 0L;

      // BOMによるEncodingの自動判別を有効にしてMemoryStreamから読み込む
      using (var bomTransparentStream = new BomTransparentStream(stream, discardBom: true)) {
        // Streamの先頭4バイトを読み込み、Encodingの自動判別を行う
        bomTransparentStream.ReadPreamble();

        // 自動判別されたEncodingを参照する
        var e = bomTransparentStream.DetectedEncoding;

        Console.Write($"{e.WebName,12} {(e.Preamble.Length == 0 ? "   " : "BOM")} : ");

        // 自動判別されたEncodingに基づいてStreamReaderを作成、内容を読み込む
        using (var reader = new StreamReader(bomTransparentStream, encoding: bomTransparentStream.DetectedEncoding)) {
          Console.WriteLine(reader.ReadToEnd());
        }
      }
    }
  }

  static readonly Encoding shift_jis =
#if NETFRAMEWORK
    Encoding.GetEncoding("shift_jis");
#else
    // `dotnet add sample.csproj package System.Text.Encoding.CodePages`
    CodePagesEncodingProvider.Instance.GetEncoding("shift_jis");
#endif
}
実行結果
       utf-8 BOM =>        utf-8 BOM : 日本語
       utf-8     =>        utf-8     : 日本語
    utf-16BE BOM =>     utf-16BE BOM : 日本語
    utf-16BE     =>        utf-8     : e�g,��
      utf-16 BOM =>       utf-16 BOM : 日本語
      utf-16     =>        utf-8     : �e,g��
    utf-32BE BOM =>     utf-32BE BOM : 日本語
    utf-32BE     =>        utf-8     : e�g,��
      utf-32 BOM =>       utf-32 BOM : 日本語
      utf-32     =>        utf-8     : �e,g��
   shift_jis     =>        utf-8     : ���{�