# 区分失败原因:不存在不等于不合法
学习目标:把可供机器判断的失败分类与供人阅读的错误消息分开,避免把所有读取失败都解释成“没有配置”。
前置知识:文件读取、函数返回、基本异常;可先读表达与传播失败。版本见主题基线。
导航:问题与边界 · 关键差异 · Go · Java · Python · 运行与验证
# 问题与边界
配置文件只允许精确字节 fast 或 safe,无 BOM、空白或末尾换行。这是有意采用的小型 ASCII 协议;不是通用配置格式。读取成功但字节不同属于内容不合法,包括空文件、fast\n、非 ASCII 字节;文件不存在属于缺失;权限、目录误用和设备错误等仍属于其他 I/O 失败。
返回成功模式,或者可区分的失败;本篇不恢复。缺失、内容错误和其他 I/O 错误不能靠比较错误消息字符串判断。三个程序创建独立临时目录,覆盖两个合法值、三种坏内容和一个缺失路径。其他 I/O 错误保留传播,但不使用改权限这种跨平台不稳定的方法伪造运行证据。
# 关键差异
| 语言 | 内容错误 | 缺失 | 包装后的判断 |
|---|---|---|---|
| Go | 稳定 sentinel ErrInvalid |
os.ErrNotExist |
errors.Is 沿链查找 |
| Java | 专用受检异常 InvalidConfig |
NoSuchFileException |
在原始类型边界捕获 |
| Python | InvalidConfig(ValueError) |
FileNotFoundError |
子类异常先匹配 |
选 Go、Java、Python 分别展示错误值身份、受检异常分类和动态异常分类。与上一文不同,此处加载器不把不同原因全包装成同一外层类型;上下文可以由外部展示层添加,但不能破坏分类接口。
# 各语言实现
# Go
预期依次输出 fast、safe、三行 invalid、一行 missing。
Gopackage main
import (
"errors"
"fmt"
"os"
"path/filepath"
)
var ErrInvalid = errors.New("invalid config")
func load(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil { return "", fmt.Errorf("read config: %w", err) }
value := string(data)
if value != "fast" && value != "safe" {
return "", fmt.Errorf("mode: %w", ErrInvalid)
}
return value, nil
}
func classify(path string) (string, error) {
value, err := load(path)
switch {
case err == nil: return value, nil
case errors.Is(err, ErrInvalid): return "invalid", nil
case errors.Is(err, os.ErrNotExist): return "missing", nil
default: return "", err
}
}
func run() (err error) {
dir, err := os.MkdirTemp("", "classify-")
if err != nil { return err }
defer func() { err = errors.Join(err, os.RemoveAll(dir)) }()
path := filepath.Join(dir, "mode")
for _, item := range []struct{ input, want string }{
{"fast", "fast"}, {"safe", "safe"}, {"", "invalid"},
{"fast\n", "invalid"}, {"\xff", "invalid"},
} {
if err := os.WriteFile(path, []byte(item.input), 0600); err != nil { return err }
got, err := classify(path)
if err != nil { return err }
if got != item.want { return errors.New("wrong category") }
fmt.Println(got)
}
if err := os.Remove(path); err != nil { return err }
got, err := classify(path)
if err != nil { return err }
if got != "missing" { return errors.New("wrong missing category") }
fmt.Println(got)
return nil
}
func main() { if err := run(); err != nil { panic(err) } }
每次 errors.New("invalid config") 都会产生新的错误值,不能在分支里重新构造一个再比较。共享 sentinel 提供稳定身份;需要行号等数据时再用结构化错误和 errors.As,不要从消息中解析。参见 errors 包。
# Java
预期同 Go。
Javaimport java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
public class Main {
static class InvalidConfig extends Exception {
InvalidConfig() { super("invalid config"); }
}
static String load(Path path) throws IOException, InvalidConfig {
byte[] data = Files.readAllBytes(path);
if (Arrays.equals(data, new byte[]{'f', 'a', 's', 't'})) return "fast";
if (Arrays.equals(data, new byte[]{'s', 'a', 'f', 'e'})) return "safe";
throw new InvalidConfig();
}
static String classify(Path path) throws IOException {
try { return load(path); }
catch (InvalidConfig e) { return "invalid"; }
catch (NoSuchFileException e) { return "missing"; }
}
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("classify-");
Path path = dir.resolve("mode");
try (AutoCloseable cleanup = () -> {
Files.deleteIfExists(path);
Files.delete(dir);
}) {
String[] inputs = {"fast", "safe", "", "fast\n", "é"};
String[] wants = {"fast", "safe", "invalid", "invalid", "invalid"};
for (int i = 0; i < inputs.length; i++) {
Files.write(path, inputs[i].getBytes(StandardCharsets.UTF_8));
String got = classify(path);
if (!got.equals(wants[i])) throw new AssertionError("wrong category");
System.out.println(got);
}
Files.delete(path);
String got = classify(path);
if (!got.equals("missing")) throw new AssertionError("wrong missing category");
System.out.println(got);
}
}
}
NoSuchFileException 是文件系统操作报告缺失的专用异常;其他 IOException 不被这个捕获分支消费。这里面向默认本地文件系统,第三方文件系统提供者的异常分类须另查其契约。参见 NoSuchFileException 与 Files.readAllBytes。坏内容用受检异常使调用者看到不同于 I/O 的失败通道。
# Python
预期同 Go。
Pythonfrom pathlib import Path
from tempfile import TemporaryDirectory
class InvalidConfig(ValueError):
pass
def load(path: Path) -> str:
data = path.read_bytes()
if data not in (b"fast", b"safe"):
raise InvalidConfig("invalid config")
return data.decode("ascii")
def classify(path: Path) -> str:
try:
return load(path)
except InvalidConfig:
return "invalid"
except FileNotFoundError:
return "missing"
with TemporaryDirectory(prefix="classify-") as directory:
path = Path(directory) / "mode"
for data, want in [(b"fast", "fast"), (b"safe", "safe"),
(b"", "invalid"), (b"fast\n", "invalid"),
(b"\xff", "invalid")]:
path.write_bytes(data)
got = classify(path)
assert got == want
print(got)
path.unlink()
got = classify(path)
assert got == "missing"
print(got)
先检查有限字节集合再解码,避免引入与 Java 替换解码策略不同的失败边界。FileNotFoundError、PermissionError、IsADirectoryError 都属于 OSError 家族,因此先捕获整个 OSError 再返回 missing 会误分类。参见 Python OS 异常层次。
# 迁移误区与适用限制
错误消息用于诊断,不是稳定协议。临时目录路径、操作系统语言、底层库升级都可能改变消息;错误类别才适合驱动策略。
“先 exists 再读取”也不是分类方案:检查与读取之间文件可能被删除,并且检查失败与不存在未必等价。应直接执行读取,再判断该次操作的结果。符号链接目标缺失、父目录缺失也可能报告不存在;本例只在自己创建的可信临时目录里演示,不把“缺失”解释成安全授权。
classify 返回的是演示界面的分类标签,不是建议库函数返回任意字符串枚举。真实库保持 load 的类型化失败,让各调用方自行决定报告还是恢复。
# 运行与验证
预期六行输出,语义检查覆盖合法集合两端、空值、尾随换行、非 ASCII 和真实缺失。文件内容按字节比较,清理仅移除自己的临时目录;不承诺刷新到稳定存储。执行事实与未覆盖的权限、设备和清理故障见验证记录,本文未单独运行示例。