C# 输入与输出:从控制台到文件的全链路指南
开篇:程序的「嘴巴」和「耳朵」
想象你正在经营一家餐厅。顾客点菜(输入)、厨房做菜(处理)、服务员上菜(输出)——这是一个完整的流程。如果服务员听不懂顾客说什么,或者上错了菜,餐厅就无法运转。
程序也一样。输入是程序从外部世界获取数据(用户键盘、文件、网络),输出是程序把处理结果呈现给外部(屏幕、文件、数据库)。C# 提供了从「最简陋的控制台对话」到「高效的文件管道」一整套工具,理解它们的使用场景,能让你的程序真正「活」起来。
一、Console 控制台:最基础的对话窗口
Console 类是 C# 中最简单的 IO 工具,适合学习调试、小型工具、演示程序。它就像餐厅的「前台对讲机」——功能简单,但必不可少。
1.1 输出:Write 与 WriteLine 的区别
// WriteLine:输出后自动换行(像说完一句话按回车)
Console.WriteLine("Hello, World!"); // 输出后光标移到下一行
Console.WriteLine("第二行"); // 在新行输出
// Write:输出后不换行(像说话说一半,等着继续)
Console.Write("姓名:");
Console.Write("张三"); // 两句连在一起输出
Console.WriteLine(); // 单独换行
// 格式化输出的三种方式
string name = "李四";
int age = 25;
// 方式一:字符串拼接(最原始)
Console.WriteLine("姓名:" + name + ",年龄:" + age);
// 方式二:占位符(C# 经典方式)
Console.WriteLine("姓名:{0},年龄:{1}", name, age);
// 方式三:字符串插值(C# 6.0+,推荐)
Console.WriteLine($"姓名:{name},年龄:{age}");
// 格式化控制
double price = 19.99;
Console.WriteLine($"价格:{price:C}"); // 货币格式:¥19.99
Console.WriteLine($"价格:{price:F2}"); // 两位小数:19.99
Console.WriteLine($"年龄:{age:D3}"); // 三位补零:025易错提醒:WriteLine 的占位符索引从 0 开始,{0} 对应第一个参数,{1} 对应第二个。索引越界会抛出 FormatException。
1.2 输入:Read、ReadLine、ReadKey 的适用场景
// ReadLine:读取一整行输入(用户按回车结束)
Console.Write("请输入你的名字:");
string? name = Console.ReadLine(); // 返回 string?,可能为 null
Console.WriteLine($"你好,{name ?? "陌生人"}");
// Read:读取单个字符(返回 int,表示字符的 Unicode 编码)
Console.Write("按任意键继续...");
int keyCode = Console.Read(); // 阻塞等待,返回字符编码
Console.ReadKey(); // 清除缓冲区中的回车符
// ReadKey:读取按键,无需回车,支持功能键
Console.Write("按 Y 确认,N 取消:");
ConsoleKeyInfo key = Console.ReadKey();
if (key.Key == ConsoleKey.Y)
Console.WriteLine("\n已确认");
else if (key.Key == ConsoleKey.N)
Console.WriteLine("\n已取消");方法对比:
| 方法 | 返回值 | 结束方式 | 适用场景 |
|---|---|---|---|
ReadLine() | string? | 按回车 | 读取文本输入 |
Read() | int | 按回车 | 逐字符读取(少见) |
ReadKey() | ConsoleKeyInfo | 任意键 | 快捷键、密码输入 |
易错提醒:ReadLine() 返回的是 string?(可空字符串),用户直接按回车会得到空字符串 "",而非 null。处理输入时务必检查空值。
1.3 类型转换异常:最常见的崩溃来源
// 危险代码:用户输入非数字时直接崩溃
Console.Write("请输入年龄:");
string input = Console.ReadLine() ?? "";
int age = int.Parse(input); // 输入 "abc" 时抛出 FormatException!
// 安全方案一:int.TryParse(推荐)
Console.Write("请输入年龄:");
if (int.TryParse(Console.ReadLine(), out int safeAge))
{
Console.WriteLine($"你输入的年龄是:{safeAge}");
}
else
{
Console.WriteLine("输入无效,请输入数字!");
}
// 安全方案二:循环验证直到输入正确
int validAge;
while (true)
{
Console.Write("请输入年龄(1-150):");
if (int.TryParse(Console.ReadLine(), out validAge) && validAge >= 1 && validAge <= 150)
break;
Console.WriteLine("输入无效,请重新输入!");
}二、文件 IO 流:程序的数据仓库
控制台适合调试,但真实程序需要持久化存储。文件 IO 就像餐厅的「食材仓库」——需要分类存放(不同文件类)、按需取用(读取方式)、及时关门(释放资源)。
2.1 File vs FileInfo:静态工具与对象实例
// File:静态类,适合一次性操作
string path = "test.txt";
// 写入(覆盖)
File.WriteAllText(path, "Hello, File!");
// 追加
File.AppendAllText(path, "\n第二行");
// 读取
string content = File.ReadAllText(path);
string[] lines = File.ReadAllLines(path);
// 存在性检查
if (File.Exists(path))
{
Console.WriteLine($"文件大小:{new FileInfo(path).Length} 字节");
}
// 删除
File.Delete(path);
// FileInfo:实例类,适合多次操作同一文件
FileInfo fileInfo = new FileInfo("data.txt");
if (!fileInfo.Exists)
{
using StreamWriter writer = fileInfo.CreateText();
writer.WriteLine("创建文件");
}
Console.WriteLine($"文件名:{fileInfo.Name}");
Console.WriteLine($"完整路径:{fileInfo.FullName}");
Console.WriteLine($"扩展名:{fileInfo.Extension}");
Console.WriteLine($"创建时间:{fileInfo.CreationTime}");选择建议:
- 一次性操作(读一次、写一次)→
File - 多次操作同一文件(检查属性、多次读写)→
FileInfo - 需要目录信息 →
Directory/DirectoryInfo
2.2 StreamReader / StreamWriter:文本流的精细控制
// StreamWriter:逐行写入,支持编码设置
string logPath = "app.log";
// using 语句确保资源释放(关键!)
using (StreamWriter writer = new StreamWriter(logPath, append: true, Encoding.UTF8))
{
writer.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 程序启动");
writer.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 用户登录");
} // 这里自动调用 Dispose(),关闭文件流
// StreamReader:逐行读取,内存友好
using (StreamReader reader = new StreamReader(logPath, Encoding.UTF8))
{
string? line;
while ((line = reader.ReadLine()) != null) // 逐行读取,不一次性加载全部
{
Console.WriteLine(line);
}
}易错提醒:忘记使用 using 或手动调用 Dispose() 会导致文件被占用,其他程序无法访问。这是新手最常见的文件 IO 错误!
// 错误示例:文件被锁定
StreamWriter badWriter = new StreamWriter("locked.txt");
badWriter.WriteLine("内容");
// 忘记关闭!此时文件被占用,其他进程无法读写
// 程序异常退出后,文件句柄可能仍被系统持有
// 正确做法:using 语句或 try-finally
using StreamWriter goodWriter = new StreamWriter("unlocked.txt");
goodWriter.WriteLine("内容");
// 离开作用域自动释放2.3 BinaryReader / BinaryWriter:二进制数据的精确读写
// 写入二进制数据(结构紧凑,适合自定义格式)
string binPath = "data.bin";
using (BinaryWriter writer = new BinaryWriter(File.Open(binPath, FileMode.Create)))
{
writer.Write(42); // int:4 字节
writer.Write(3.14159); // double:8 字节
writer.Write("Hello"); // string:长度前缀 + UTF8 字节
writer.Write(true); // bool:1 字节
writer.Write(new byte[] { 0xFF, 0x00, 0xAA }); // 原始字节
}
// 读取二进制数据(顺序必须与写入一致!)
using (BinaryReader reader = new BinaryReader(File.Open(binPath, FileMode.Open)))
{
int intValue = reader.ReadInt32(); // 42
double doubleValue = reader.ReadDouble(); // 3.14159
string stringValue = reader.ReadString(); // "Hello"
bool boolValue = reader.ReadBoolean(); // true
byte[] bytes = reader.ReadBytes(3); // { 0xFF, 0x00, 0xAA }
Console.WriteLine($"读取:{intValue}, {doubleValue}, {stringValue}, {boolValue}");
}适用场景:自定义文件格式(如游戏存档、图片元数据)、网络协议数据包、高性能序列化。
2.4 大文件分块读取:避免内存爆炸
// 危险:一次性读取 2GB 文件到内存
// string hugeContent = File.ReadAllText("huge.log"); // OutOfMemoryException!
// 正确:分块读取
string hugePath = "huge.log";
byte[] buffer = new byte[4096]; // 4KB 缓冲区
using FileStream fs = new FileStream(hugePath, FileMode.Open, FileAccess.Read);
int bytesRead;
while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0)
{
// 处理当前块(如统计、搜索、转换)
ProcessChunk(buffer, bytesRead);
// 显示进度
double progress = (double)fs.Position / fs.Length * 100;
Console.Write($"\r进度:{progress:F1}%");
}2.5 路径处理:跨平台的陷阱
// 错误:硬编码路径分隔符(Windows 用 \,Linux/macOS 用 /)
string badPath = "C:\\Users\\Admin\\file.txt"; // 在 Linux 上完全无效!
// 正确:使用 Path 类组合路径
string goodPath = Path.Combine("data", "logs", "app.log");
// Windows: data\logs\app.log
// Linux: data/logs/app.log
// 常用路径操作
string fullPath = Path.GetFullPath("relative.txt"); // 转绝对路径
string? dirName = Path.GetDirectoryName(fullPath); // 获取目录
string fileName = Path.GetFileName(fullPath); // 获取文件名
string extension = Path.GetExtension(fullPath); // 获取扩展名
string tempPath = Path.GetTempPath(); // 系统临时目录
string randomFile = Path.GetRandomFileName(); // 随机文件名
// 特殊目录
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string myDocs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);三、标准输入输出流的底层原理
控制台 IO 的本质是标准流(Standard Streams)。理解这个抽象,能让你把程序输出重定向到文件、管道,甚至网络。
// 标准流的三件套
// stdin (0):标准输入,默认键盘
// stdout (1):标准输出,默认屏幕
// stderr (2):标准错误,默认屏幕
// 重定向输出到文件
Console.SetOut(new StreamWriter("output.txt"));
Console.WriteLine("这行会写入文件,而非屏幕!");
// 恢复控制台输出(Windows 专用,跨平台需其他方式)
var standardOutput = new StreamWriter(Console.OpenStandardOutput());
standardOutput.AutoFlush = true;
Console.SetOut(standardOutput);
// 从文件读取输入(模拟用户输入)
Console.SetIn(new StreamReader("input.txt"));
string? line = Console.ReadLine(); // 从文件读取,而非键盘实际应用:单元测试中捕获 Console 输出、日志收集、命令行工具管道操作。
四、异步 IO:不卡界面的关键
同步 IO 会阻塞线程,在 UI 程序中导致界面冻结,在服务器中浪费线程资源。异步 IO 让程序在等待磁盘/网络时去处理其他任务。
// 同步读取(阻塞线程)
string syncContent = File.ReadAllText("large.txt"); // 线程等待磁盘完成
// 异步读取(不阻塞,适合 UI 程序和高并发服务器)
async Task<string> ReadFileAsync(string path)
{
using StreamReader reader = new StreamReader(path);
return await reader.ReadToEndAsync(); // 线程释放,完成后回调
}
// 异步写入
async Task WriteLogAsync(string message)
{
string logPath = "app.log";
string logLine = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}";
// 异步追加,不阻塞主线程
await File.AppendAllTextAsync(logPath, logLine);
}
// 并行处理多个文件
async Task ProcessFilesAsync(string[] files)
{
var tasks = files.Select(async file =>
{
string content = await File.ReadAllTextAsync(file);
return Process(content);
});
var results = await Task.WhenAll(tasks); // 并行等待所有完成
}五、序列化与反序列化:对象的「冷冻与解冻」
序列化把内存中的对象转换为可存储/传输的格式(JSON、XML、二进制),反序列化则还原。就像把新鲜蔬菜冷冻保存,吃的时候再解冻。
using System.Text.Json;
// 定义可序列化的类
public class Student
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int Age { get; set; }
public List<string> Courses { get; set; } = new();
}
// JSON 序列化(推荐:.NET 内置,高性能)
Student student = new Student
{
Id = 1,
Name = "张三",
Age = 20,
Courses = { "数学", "英语" }
};
// 序列化:对象 → JSON 字符串
string json = JsonSerializer.Serialize(student, new JsonSerializerOptions
{
WriteIndented = true // 格式化输出,便于阅读
});
Console.WriteLine(json);
// 写入文件
File.WriteAllText("student.json", json);
// 反序列化:JSON 字符串 → 对象
string readJson = File.ReadAllText("student.json");
Student? restored = JsonSerializer.Deserialize<Student>(readJson);
Console.WriteLine($"还原:{restored?.Name},{restored?.Age} 岁");常用序列化方案对比:
| 方案 | 命名空间 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
System.Text.Json | .NET 内置 | 高性能、低内存 | 功能相对基础 | Web API、微服务 |
Newtonsoft.Json | NuGet 包 | 功能丰富、灵活 | 性能略低 | 复杂对象、旧项目兼容 |
XmlSerializer | 内置 | 可读性好、标准 | 冗长、慢 | 配置文件、遗留系统 |
BinaryFormatter | 内置(已过时) | 简单 | 不安全、跨平台差 | 避免使用 |
六、综合实战案例
案例 1:控制台学生信息管理系统的输入输出模块
using System.Text.Json;
public class StudentManager
{
private List<Student> _students = new();
private const string DATA_FILE = "students.json";
public void Run()
{
LoadData();
while (true)
{
Console.WriteLine("\n=== 学生管理系统 ===");
Console.WriteLine("1. 添加学生 2. 查看所有 3. 保存退出");
Console.Write("请选择:");
switch (Console.ReadLine())
{
case "1": AddStudent(); break;
case "2": ShowAll(); break;
case "3": SaveData(); return;
default: Console.WriteLine("无效选项!"); break;
}
}
}
private void AddStudent()
{
Console.Write("姓名:");
string name = Console.ReadLine() ?? "未知";
Console.Write("年龄:");
if (!int.TryParse(Console.ReadLine(), out int age) || age <= 0)
{
Console.WriteLine("年龄无效!");
return;
}
_students.Add(new Student { Id = _students.Count + 1, Name = name, Age = age });
Console.WriteLine("添加成功!");
}
private void ShowAll()
{
foreach (var s in _students)
Console.WriteLine($"ID:{s.Id} 姓名:{s.Name} 年龄:{s.Age}");
}
private void LoadData()
{
if (File.Exists(DATA_FILE))
{
string json = File.ReadAllText(DATA_FILE);
_students = JsonSerializer.Deserialize<List<Student>>(json) ?? new();
}
}
private void SaveData()
{
string json = JsonSerializer.Serialize(_students, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(DATA_FILE, json);
Console.WriteLine("数据已保存!");
}
}
// 测试验证方法:
// 1. 运行程序,添加 3 个学生
// 2. 选择 3 保存退出
// 3. 重新运行,选择 2 查看所有,确认数据已恢复案例 2:本地日志文件的读写实现
public class Logger : IDisposable
{
private readonly StreamWriter _writer;
private readonly object _lock = new();
public Logger(string logPath)
{
// 确保目录存在
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
// 以追加模式打开,UTF8 编码,自动刷新
_writer = new StreamWriter(logPath, append: true, Encoding.UTF8)
{
AutoFlush = true // 立即写入,避免缓存丢失
};
}
public void Log(string level, string message)
{
lock (_lock) // 多线程安全
{
string logLine = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{level}] {message}";
_writer.WriteLine(logLine);
Console.WriteLine(logLine); // 同时输出到控制台
}
}
public void Info(string msg) => Log("INFO", msg);
public void Error(string msg) => Log("ERROR", msg);
public void Dispose() => _writer?.Dispose();
}
// 使用示例
using var logger = new Logger("logs/app.log");
logger.Info("程序启动");
logger.Error("测试错误");
// 测试验证方法:
// 1. 运行后检查 logs/app.log 文件是否存在
// 2. 确认日志内容包含时间戳和级别
// 3. 用其他程序(如记事本)打开日志文件,确认未被占用案例 3:自定义对象的序列化存储
using System.Text.Json;
using System.Text.Json.Serialization;
public class GameSave
{
public string PlayerName { get; set; } = "";
public int Level { get; set; }
public int Health { get; set; }
public List<string> Inventory { get; set; } = new();
[JsonIgnore] // 不序列化此属性
public string TempData { get; set; } = "";
}
public class SaveManager
{
private const string SAVE_FILE = "savegame.json";
private readonly JsonSerializerOptions _options = new() { WriteIndented = true };
public void Save(GameSave data)
{
string json = JsonSerializer.Serialize(data, _options);
File.WriteAllText(SAVE_FILE, json);
Console.WriteLine("存档成功!");
}
public GameSave? Load()
{
if (!File.Exists(SAVE_FILE))
{
Console.WriteLine("无存档文件");
return null;
}
try
{
string json = File.ReadAllText(SAVE_FILE);
return JsonSerializer.Deserialize<GameSave>(json);
}
catch (JsonException ex)
{
Console.WriteLine($"存档损坏:{ex.Message}");
return null;
}
}
}
// 测试验证方法:
// 1. 创建 GameSave 对象,设置属性,调用 Save
// 2. 检查 savegame.json 内容格式是否正确
// 3. 注释掉部分属性,测试 Load 的反序列化容错
// 4. 手动修改 JSON 制造格式错误,验证异常捕获知识点思维导图
C# 输入与输出
│
├─ Console 控制台
│ ├─ 输出:Write / WriteLine / 格式化($ 插值推荐)
│ ├─ 输入:ReadLine(行)/ Read(字符)/ ReadKey(按键)
│ ├─ 类型安全:TryParse 防崩溃,循环验证输入
│ └─ 易错点:ReadLine 返回 string?,索引从 0 开始
│
├─ 文件 IO 流
│ ├─ File vs FileInfo:静态工具 vs 实例对象
│ ├─ 文本流:StreamReader / StreamWriter
│ │ ├─ 编码设置(UTF8 推荐)
│ │ ├─ 追加模式(append: true)
│ │ └─ 逐行读取(内存友好)
│ ├─ 二进制流:BinaryReader / BinaryWriter
│ │ └─ 顺序必须一致,适合自定义格式
│ ├─ 大文件:分块读取(4KB 缓冲区)
│ ├─ 路径处理:Path.Combine 跨平台,特殊目录获取
│ └─ 资源释放:using 语句(关键!避免文件占用)
│
├─ 标准流底层
│ ├─ stdin / stdout / stderr 三流模型
│ └─ 重定向:Console.SetOut / SetIn
│
├─ 异步 IO
│ ├─ ReadAllTextAsync / WriteAllTextAsync
│ ├─ await 释放线程,提升吞吐量
│ └─ Task.WhenAll 并行处理多文件
│
└─ 序列化
├─ JSON:System.Text.Json(内置推荐)
├─ XML:XmlSerializer(可读性好)
├─ 特性控制:[JsonIgnore] 等
└─ 安全:避免 BinaryFormatter(已过时)