标签和其他相关杂项






4.50/5 (6投票s)
一个男人用代码整理甜饼怪图片的心路历程……
引言
在互联网上,打标签是相当普遍的做法,尤其是在博客网站上。它消除了诸如“这个该放进哪个文件夹?”之类的烦恼,并使得搜索图片、视频、博客、丑陋的怪物、音乐及其他有用数据变得相当快捷高效。
然而,在台式电脑上,这并不常见。某些文件格式,如 JPEG 和 MP3,原生支持标签,但并**不是所有**文件格式都支持。这意味着,如果你想为你所有的甜饼怪图片打上标签(*别撒谎,我知道你有一些*),你就必须确保每一张图片都是“*甜饼怪.jpg*”,而不是“*甜饼怪.png*”。
我想大多数人不会这么做;这意味着为了找到他们的图片,他们会把图片放进相关的文件夹里;比如“甜饼怪”、“艾摩”、“大鸟”等等。这时,那个可怕的、可怕的“第一世界问题”就来了:“等等!如果我有一张同时有甜饼怪**和**艾摩的图片该怎么办?我该怎么做?”。
所以,在图片文件夹可能变得一团糟的威胁下,我启动了 Visual Studio,写了一个名为 ASH 的程序。这个程序管理一个给定文件夹中所有图片的数据库,并为每张图片分配标签。为什么叫“ASH”?因为当时我正在看《鬼玩人2》,而 Ash 这个名字似乎足够随意,可以用作程序名。而且我忘了关掉大写锁定键。而且这名字听起来有点神秘。是啊……
ASH 支持从其数据库中添加、删除、修改和搜索图片,这意味着你只需搜索“蓝色怪物”就可以相对高效地找到你的甜饼怪图片。
为什么不用[在此插入更高级的数据库软件]?
有几个原因,没有一个真正理性的(或者说好的)
- [在此插入更高级的数据库软件]是给无聊的办公室职员用的
- [在此插入更高级的数据库软件]的开发者们(错误地)认为《鬼玩人》三部曲非常、非常蹩脚且预算不足
- [在此插入更高级的数据库软件]不理解《芝麻街》的重要性,并把伯特和厄尼视为“仅仅是数据库中的一个条目”(并不是说 ASH 就理解了[暂时还没有],但这是值得思考的事情。)
背景
ASH 的初期开发大约花了2周时间,大部分是在凌晨一点,期间还喝了无数次激浪汽水和各种拖延。它的结构是抄袭自模仿 Booru 网站(或者至少,是我猜测 Booru 网站可能的工作方式),所以从一开始我就把它构建成一种服务器类型的东西,而不是一个独立程序。
ASH 的核心是基于一种自定义数据库格式,它由一个9字节的头部和随后的1千字节“块”组成。每个块包含一个260字节的路径、一个8字节的时间戳和756字节的标签。(我算术不行,所以这个数可能不对,但无所谓了)。在文件中,任何未使用的数据都设置为0,但如果只用了百来个字节却要加载整整一千字节的数据,这显然是巨大的浪费,所以在内存中,数据被写入一个 Block
对象。
class Block
{
public:
// ...
private:
// ...
bool pending;
int block_number;
unsigned long long time_stamp;
char * fl_path;
Tag fl_tags;
};
有两样东西没有直接编码在文件中,它们是 pending
和 block_number
;前者只是用来跟踪哪些 Block
真正需要写入磁盘,而 block_number
是用来指定写入信息的位置(实际的字节偏移量是通过将 block_number
乘以1024,然后加上9得出的;就是我在数学课上没注意听的那些线性方程之类的东西)。time_stamp
是按原样加载和写入的(尽管有一些小代码来确保字节以正确的顺序读出)。fl_path
的处理方式也和人们预期的一样;分配一个缓冲区,然后把它转储到内存中,只是没有多余的零。
另一方面,fl_tags
的处理方式则大不相同。
《奇爱博士》或:我如何学会停止恐惧并爱上炸弹
如果你随便上一个博客,你可能会注意到(除了博客本身,你懂的)它有一个标签云;就是屏幕一侧那个小框框,里面有一大堆词。当然,在开发 ASH 时,我想加入类似的功能,但我也想确保在这些标签中搜索既快速又高效。
这时,C++ 标准模板库及其 std::map<>
模板类登场了。
乍一看,我想“哦,哇!这**正是我需要的**!一个将键映射到值的对象!”。我开始将 std::map
集成到 ASH 中,并编写了加载代码来解析单个标签并添加它们。每个标签都会链接到一个 Block
。整个过程中,我当然真的以为它会完美运行,并极大地减少搜索图片所需的时间。
然后我实际运行了它。
它**慢得要死**。加载一个500KB的文件足足花了10秒钟。现在,得承认,这可能不是 std::map
的错,我敢肯定有几百万个无聊的、数学上的原因可以解释为什么这种方法拖垮了 ASH,但是数学很蠢。重要的是它很慢,而且使用 std::map
使得进行部分匹配变得极其困难。因此,我开始拥抱炸弹删除键,并暴力拆解 ASH 的闪亮新丑陋旧代码。
第四集:新希望
几seconds小时后,是时候开始重建标签系统了,也许还可以加入一些新的、多余的功能,比如“搜索”和“更新标签”。
结果我跑去看了《黑暗军团》。
在看完 Ash 英勇地战胜恶灵,勇敢地阻止他们获取《死灵之书》后,我意识到一件超级重要的事情。如果我不完成这个,**恶灵就会赢**(当时是凌晨四点,我喝了太多激浪汽水。别评判我)。我立即开始重新设计标签系统。起初我想我应该用某种字典完全替换 std::map
…… 但我数学很烂,我想出的算法似乎会比我能接受的多占用几个G的内存(我数学**真的**很烂)。这意味着我必须把整个东西都删掉。大约在这个时候,我在我的(纸质)笔记本上开了一个新章节,标题是“新世界秩序”,并开始琢磨新标签系统的具体细节。
结果就是 ASH 目前的 Tag
对象
class Tag
{
public:
// ...
int match(const Tag& t) const;
int match(const char* t, long len=0) const;
private:
// ...
long length;
IAllocate* allocator;
};
新的标签系统在几个方面更好。首先,可能也是最重要的一点,标签在加载后不必被拆分;事实上,它们**永远**不需要被拆分,除非你是个可怕、卑鄙的人,喜欢毁掉它们的生活。
树形结构现在已经没有了,取而代之的是一个数组;算不上惊艳,但速度更快了。此外,Tag
支持部分匹配,以及同时匹配多个标签。
Tag
或多或少就是带有自动清理功能的美化版字符串。length
是标签实际占用的字节数,而 allocator
只是管理它的内存。有点臃肿,但能用。
Tag
带来的最重要的特性是它的匹配方法,它会返回与输入标签的匹配次数,像这样(请原谅我的微软画图“技巧”):
为了节省处理时间和内存,整个操作是就地完成的,没有使用递归(这个图不完全准确)。目前,匹配方法是两个独立的函数,它们做的事情几乎完全一样,主要是因为在我睡了几个小时后,我阻止恶灵获胜的目标稍微淡化了(别评判我!)。
在追加标签时,会使用一个类似但稍微复杂的过程,以确保只追加唯一的标签。其余的都被忽略了。
其他
在某个时候,事情变得很明显,除了以科学的名义彻底搞砸 ASH,我还必须编写自定义的分配器。为什么?因为我能(当然也是因为我想尽量减少重新调整大小的次数)。
class DefAllocator: public IAllocate
{
public:
// ...
void alloc(long s);
void alloc_exact(long s);
// ...
private:
long sz, rz;
byte * data;
};
默认分配器根据请求的空间大小(s
)和重新调整大小的次数(rz
)来调整 data
的大小。
long size = ((s*(++rz)*rz) >> 1);
if(size < s) size = (s + rz) + 1;
其理论是,浪费内存比浪费CPU要好。
列表,列表,列表,还有……蔓越莓汁?!
Ash::list
的初衷是成为 std::vector
的一个非常非常臃肿的版本(但我向你保证,是以最好的方式)。与 Ash::DefAllocator
的思路非常相似,list
分配越来越多的内存,以减少重新调整大小操作的次数。因为在标签系统那次可怕的爆炸重构事件之后,我试图尽量少做改动,所以 list
看起来就像是 vector
的一个巨大山寨品。
template<typename item>
class list
{
public:
// ...
private:
// Boring internal junk no one cares about...
long size, mem_count;
long resizes;
item* members;
SizeType st;
};
关于 list
唯一特别独特的地方是,它允许你改变它的大小调整方式(SizeType
),这样你就可以防止不经常调整大小的 list
失控增长。
管道、随机数生成器和 GNU/Linux
如果你粗略地看一下 ASH 的源代码,你可能会注意到一些对 GNU/Linux 和 GCC 的引用,然后在几分钟后,当你发现 ASH 在 *nix 系统下无法(或者说,不再能)编译时,会开始愤怒地砸你的显示器/键盘/鼠标/任何东西。这纯粹是懒惰。我还没写那部分代码。
然而,这意味着 ASH 中那些不能只用标准库实现的部分都有接口;特别是 IPipeServer
和 IRandomProvider
(IAllocate
也是一个接口,但它不算,因为它不是出于跨平台原因而这样实现的[别问])。
IPipeServer
基本上只是相关系统调用的一个包装器。IRandomProvider
有点不同,因为它有三个独立的实现。第一个是 DefRandomProvider
,它实际上是标准 srand()
和 rand()
函数的包装器;这有点没用,因为标准库本身就已经是跨平台的了。另外两个是 WinRandomProvider
和 NixRandomProvider
,它们提供了比 DefRandomProvider
更具加密安全性的方法。在 Windows 上,这是一个 CryptGenRandom()
的包装器,并处理一些重要的杂事,比如获取加密上下文和抛出明目张胆的异常。在 *nix 系统上,会从 /dev/random 读取一个种子,然后从中生成随机数(因为为了几KB的数据花15分钟确实很荒谬)。
法西斯主义
作为又一个实验,我加入了 AccessControl
来管理登录。
class AccessControl
{
public:
// ...
void generateLogin(Token& out);
bool isLoggedIn(const Token& in) const;
bool logoutUser(const Token& in);
// ...
private:
IRandomProvider * irp;
list<Token> logins;
};
AccessControl
生成32字节的会话令牌,很可能可以用来管理单个会话(我承认,这在技术上还没测试过……)。我确定这在某些地方会很重要。
整合
可惜,我想不出办法把《音乐之声》融入这一节。抱歉。
你现在可能已经猜到了,Block
们并非自行其是。它们由
一个单独的 File
对象管理
class File
{
public:
// ...
private:
// ...
int header_sz;
std::fstream file_obj;
list<Block> file_blocks;
};
header_sz
是 ASH 头的大小。还记得我说过它是9个字节吗?嗯……我撒了点谎(一点点)。ASH 数据库中的第一个字节是头的总大小,不包括第一个字节本身。在 ASH 的当前版本中,第一个字节总是等于8;不过你实际上可以用十六进制编辑器制作自己的文件,然后把它改成任何你想要的数字。当向磁盘写入一个块时,字节偏移量是这样计算的
偏移量
= header_sz
+ (block_sz
* block_number
)
目前,额外的可用头部数据并未使用;这只是为了未来的扩展。
file_obj
,如果你还没猜到的话,就是数据库文件本身的文件对象。它在程序执行期间一直存在,以保持数据库文件对外部进程的锁定,不过在程序的大部分时间里它什么也不做。
file_blocks
是从数据库文件加载的 Block
列表。目前,ASH 直接搜索这些块。
使用代码
按原样编译后,ASH 在命令行中运行。首先,它会要求用户(你?)指定 ASH 数据库的完整路径。
如果文件不存在,ASH 会直接创建它(是的,它必须是“.ash”,否则你身后站着的那位可能会生气。不,说真的。别。回。头。)。然后,你可以输入命令让 ASH 执行,具体有:“add”、“remove”、“find”、“find_path”、“tag”和“path”(当然,不带引号)。参数用空格分隔;如果任何单个参数需要包含空格(例如文件路径),请用引号括起来。
- “add [文件路径] [标签]” 向 ASH 数据库添加一个条目,包含指定的文件名和标签。如果指定的文件名已存在,它会将指定的标签与现有条目合并。标签应以空格分隔。
- “remove [文件路径]” 从数据库中删除一个条目。
- “find [标签]” 返回数据库中所有与指定标签匹配的条目列表,按匹配度从高到低排序。由于一个我懒得现在修复的相当烦人的问题,标签应以|竖线|分隔。
- “find_path [文件路径]” 查找具有指定文件路径的条目并返回其标签。
- “tag [文件路径] [标签]” 为现有条目添加指定的标签;这与“add”不同,“tag”在条目不存在时不会添加它。标签应以空格分隔。
- “path” 不带参数,返回数据库的路径(但不包括文件名)。
一系列管子管道
一旦 ASH 建立了一个数据库,它会打开一个名为“AshPipes”的命名管道,以接收来自外部进程的命令。
在这个例子中,实验 #735(别问编号怎么来的,否则那个可怕的家伙会回来)是一个 .NET 进程,它作为 ASH 的一个 GUI 包装器。当用户点击某个东西,比如说“添加图片”,它会创建一个包含“add”命令及其参数的 String
,然后发送出去。
private String sendString(String output)
{
// Exception-handling junk...
StreamWriter sw = new StreamWriter(npcs);
sw.Write(output);
sw.Flush(); // Because using is bad. (Closes the pipe, not just the StreamWriter)
String response = "";
StreamReader sr = new StreamReader(npcs);
char[] buffer = new char[1024];
Int32 count = 0;
while ((count = sr.Read(buffer, 0, 1024)) > 0)
{
response += new String(buffer, 0, count);
if (count < 1024) break;
}
return response;
}
如果命令因任何原因失败,response
将是“fail”。
当命令成功时,response
会根据发送的命令而不同;“add”、“remove”和“tag”都会返回“success”。“find”可能是最大的操作,它会返回整个匹配列表(可以轻松达到数千个)。“find_path
”返回单个块的所有标签,不太可能超过756字节。“path”返回数据库的路径,预计不会超过260字节。
ASH 对每个连接的进程一次只处理一个命令(再加上 ASH 服务器进程本身一次一个)。
但我不想!
所以,假设你对附带的“main.cc”有点反感(这**绝对**会召唤出你身后的那个可怕家伙),你就得自己设置一切,从数据库开始。
// #include "ash_database.h"
try{
db = new Ash::File(path);
if(db != nullptr){ db->loadDataBase(); }
else throw Ash::ae_memfail;
}catch(Ash::ash_error e){
cout << Ash::parse_error(e) << endl << endl;
try{
db = Ash::File::createDataBase(path);
if(db != nullptr) { db->loadDataBase(); }
}catch(Ash::ash_error e){cout << Ash::parse_error(e) << endl;}
}catch(...){
cout << "Unspecified error!" << endl;
cout << "Program cannot continue; please try again." << endl << endl;
return -1;
}
第一次尝试是打开一个现有的数据库;Ash::File
不会自己创建数据库,所以如果打开失败,它会抛出一个异常(具体来说,是 Ash::ash_error::ae_iofail
),在这种情况下,我们尝试在 path
处初始化一个数据库;ASH 非常天真,它简单地假设所有路径都是有效的。
一旦 Ash::File
对象被创建,我们调用 loadDataBase()
将其加载到内存中。
File
定义了以下方法
class File
{
public:
// ...
bool removeBlock(const std::string& path);
void addBlock(const std::string& path, const std::string& tags);
void flush();
void loadDataBase();
list<std::pair<Block*, int>> find(const std::string& search_str);
Block* findBlockNumber(int bn);
Block* findPath(const std::string& search_str);
static bool isVersionCompatible(const unsigned char* version_buffer);
static File* createDataBase(const std::string& path);
private:
// ...
};
添加和删除 Block
有点不言自明;只要记住 addBlock()
不会检查重复项。
调用 flush()
将待处理的更改写入磁盘;File
对象在销毁时会自动执行此操作,但这样你就可以满足任何已有的微观管理心理需求。
如上文某处所述,find()
返回一个结果 list
和每个结果的命中次数,按降序排列。findBlockNumber()
返回一个指向编号为 bn
的单个 Block
的指针。findPath()
返回一个指向路径与 search_str
匹配的单个 Block
的指针(使用 std::string::operator=()
)。
isVersionCompatible()
在内部用于检查与待加载的 ASH 数据库的兼容性。它期望一个9字节的缓冲区大小,并且有时会导致非常、非常、非常戏剧性的蓝屏(不是真的)。
区块
Ash::File
会自己处理所有事情,前提是你没有修改任何 Block
、更新标签或做任何事情。在那些疯狂、不可能的情况下,你可能真的想要修改现有数据,你就必须直接处理有问题的 Block
。
class Block
{
// ...
public:
// ...
int getBlockLocation() const;
long long getTimeStamp() const;
char* getPath() const;
std::string getTags() const;
void setPath(const char* nPath);
void setTags(const char* nTags);
Tag& modTags();
void killBlock();
void writeBlock(std::fstream& output, int header_sz) const;
private:
// ...
};
setter 和 getter 的行为完全符合预期;它们设置和获取数据(惊喜!)。killBlock()
将 Block
标记为待删除(即,当数据库被刷新时,这个特定区域会被写入零)。writeBlock()
在 Ash::File
内部用于写入文件时,嗯,写入文件。你当然也可以用 writeBlock()
来实现你自己的邪恶目的,只要记住它是在块的偏移量处写入块,而不是在你邪恶的邪恶位置。modTags()
返回对 Block
的 Tag
对象的引用,这样你就可以对它进行扭曲的医学实验了。
标签
Tag
看起来像这样
class Tag
{
public:
// Boring stuff...
void setTags(const char* t, long len = 0);
void trim();
void writeTags(char* out, long max) const;
long getTagCount() const;
std::string getTags() const;
char* getTagPtr() const;
int match(const Tag& t) const;
int match(const char* t, long len=0) const;
private:
// Too scary for the internet! (...)
};
如你所见,Tag
提供了各种无用的漂亮功能。
setTags()
实际上跳过了常规步骤,完全改变了所有标签;当仅仅追加标签变得太困难时,就用这个。trim()
会调整内部缓冲区的大小,使其没有未使用的字节,如果你要把 ASH 移植到 Z80 之类的东西上,这可能会有帮助。在实践中,裁剪缓冲区只会拖慢 ASH 的速度;然而,如果 A.) 你真的在只有1K内存的 Z80 上运行这个,或者 B.) 你有某种方法保证标签不会被频繁修改,并且需要内存来做更重要的事情,比如**《Hello Kitty 岛屿冒险,第二部:毛茸茸之灾》**,那么它可能会有帮助。writeTags()
将内部缓冲区复制到 out
。我确信你自己能找到它的用处。如果找不到,就重新编译一个没有它的版本什么的。**看我关不关心**(我真的,真的很关心!别让我哭!)。getTagCount()
返回 Tag
对象中的标签数量;标签由竖线分隔;你自己算吧。你可能还会注意到 Tag
有一个(上面没写)方括号操作符引用之类的东西(operator[]()
),它返回指定索引处的标签字符串。信不信由你,这两者实际上是相关的。现在,你可能会想做这样的事
/* The following is an example of a bad practice; don't do it. */
string a;
for ( long i = 0; i < getTagCount(); i++ ){
a = my_tag[i];
// Do stuff with "a" here
}
从技术上讲,上面这段代码可以编译。但它是错的。所以不要这样做。那是不好的。说真的。别。如果你这么做了,你身后的那个家伙可能会再次生气。这是低效的。所有那些讨厌的 C 程序员都会把你当作反面教材,用来证明为什么 C++ 那么烂,以及为什么我们应该用 C 来做所有事情。真的。(**但还是试试吧!**)
getTags()
以 std::string
的形式返回所有内部标签,其明确目的是以一种更便于编辑的形式获取所有内部标签。例如,如果你有一个特别复杂的操作,而 Tags 目前完全不支持(参见:正则表达式),你可以这样做
/* The following is also something that will get you burned at the stake. */
string some_tags = my_tag.getTags();
some_regex_function(some_tags, some_input);
my_tag.setTags(my_tag.c_str(), my_tag.length());
getTagPtr()
直接返回一个指向 Tag
内部缓冲区的指针;所以不要对它做任何坏事。
match()
方法,顾名思义,是用来匹配标签的
Tag my_tag;
my_tag.setTags("the_ramones|awesome|punk|blitzkrieg_bop|multiple_bops");
int matches = my_tag.match("blitz");
cout << "Matches: " << matches << endl;
// Outputs "Matches: 1"
matches = my_tag.match("bop");
cout << "Matches: " << matches << endl;
// Outputs "Matches: 2"
如果你跳过了,背景部分有关于这到底是如何工作的更详细的解释(我不怪你,我也会跳过)。
Tag
还包含几个相当重要的运算符,因为没有它们,整个 ASH 可能会(可能)瘫痪
class Tag
{
public:
// ...
Tag& operator=(const Tag& t);
Tag& operator=(Tag&& t);
Tag& operator+=(const Tag& t);
bool operator==(const Tag& t) const;
std::string operator[](long i) const;
// ...
private:
// ...
};
赋值运算符实际上非常、非常、非常通用。它们的作用完全符合预期;而且可悲的是,它们甚至没有任何奇怪的行为(据我所知),如果不加以考虑就可能导致整个网络崩溃。operator+=()
有点不同,因为它对实际添加到 Tag
的标签有点挑剔(如上文某处简要提到的),因为我认为一个普通的追加运算符会很傻。基本上,它只添加唯一的标签,而不是追加**所有**标签
Tag my_tag;
my_tag.setTags("the_ramones|awesome|punk|blitzkrieg_bop|multiple_bops");
Tag my_other_tag;
my_other_tag.setTags("the_ramones|punk|blitz");
my_tag += my_other_tag;
cout << my_tag.getTags() << endl;
// Outputs "the_ramones|awesome|punk|blitzkrieg_bop|multiple_bops|blitz"
就像 ASH 的其他所有东西一样,这个运算符坚信速度比空间效率更重要,因此使用的内存比实际使用的要多(是的,这确实说得通)。添加的唯一标签越多,浪费的空间就越少。
比较运算符非常字面化,只有在两个 Tag
完全相同时才返回 true;对于更模糊的比较,我强烈推荐使用 match()
函数(不,这绝对没有偏见!)。方括号操作符(operator[]()
),如上所述,返回指定索引处的标签。
Tag my_tag;
my_tag.setTags("the_ramones|awesome|punk|blitzkrieg_bop|multiple_bops");
cout << my_tag[3] << endl;
// Outputs "blitzkrieg_bop"
cout << my_tag[105] << endl;
// Crashes, because *you* didn't catch the exception!
已知问题
1) 它无法在我的 Linuxuuu 上编译!
实际上,如果你从 main.cc 中移除多线程/管道处理部分,技术上是可以编译的,但我明白你的意思,愤怒的 GNU/Linux 用户。我肯定会完全无视整个 GNU/Linux 社区,永远、永远都不会去管它最终会处理它的。(看到我刚才做了什么吗?现在充满了各种悬念。“他刚才只是在开玩笑吗?**还是他会无视我们的困境?**”)虽然我确实希望 ASH 是跨平台的,但有一些我认为非常严重(比甜饼怪和《鬼玩人2》加起来还要严重)、惊天动地的问题(尽管对用户来说是完全透明的,而且数量太多无法在此列出)需要优先处理(你永远猜不到是什么!)。显然你可以自己打补丁,但新版本的 ASH 可能会破坏与旧版本的兼容性(所以你可能最终不得不在我推出对 GNU/Linux 的*官方*支持之前,为每个新版本重新打补丁)。就目前而言,如果你为 GNU/Linux 编译 ASH,它可以做 Windows 版本能做的一切,除了连接到外部进程(所以不先修改代码就无法为它创建 GUI)。
2) 实验 #735 卡死了!
我把这主要归因于我对 .NET 的不熟悉;在两种特定情况下,实验 #735 会完全停止响应。第一种是向 ASH 数据库添加一个非常大的文件夹时;这**不是**一个 bug,我只是懒得添加进度条;在这些情况下,你只能等着它完成。它之所以如此之慢,是因为实验 #735 是一张一张地添加图片,每次都必须创建一个新的 String
,发送它,等待响应,然后处理下一张。我知道这很低效,但实验 #735 的初衷更多是一个测试性的东西,而不是一个实用性的东西。第二种情况是一个实际的问题。当实验 #735 进行搜索,而 ASH 返回超过约600个结果时,管道似乎被堵塞了,实验 #735 会一直冻结直到 ASH 关闭。到目前为止,这种行为在 Windows 7 和 Vista 上,在两台不同的电脑上都是一致的,所以我猜我在这里严重搞砸了什么。我可能不会负责任地处理这个问题,而是可能会使用中间文件之类的东西来解决。
3) 实验 #735 突然戏剧性地崩溃了!
现在,我不能完全确定,但我很确定这个不是我的错。在测试过程中发现,某些 GIF 文件(它们本身是无害且功能完好的)显然会导致 GDI+ 抛出一些可怕的异常并使应用程序崩溃。既然我没有写微软的 GIF 解析代码或他们的 PictureBox
控件,我想我可以安全地怪他们。技术上,微软可能已经修复了这个问题,虽然我不知道,因为我完全忘了这是在哪个 .NET 版本上发生的(**我就是这么牛**。)
据我所知,就这些了;*如果你发现任何其他问题,请大声抱怨*。
结论
这部分是我在英语课上**总是**被扣分的地方,所以我连试都懒得试了。
玩得开心!(或者不开心。扫兴鬼。)