Symbian OS 设计缺陷
本文描述了Symbian OS的许多设计缺陷,并寻求更好的解决方案,可惜Symbian已经太迟了,无法纠正。
引言
本文出现的根本原因在于其开发者对Symbian OS的不断赞美,以及那些试图使用该平台的开发者的持续抱怨。俗话说,智者承认错误。但要承认错误,首先要理解它。Symbian OS的问题在于其糟糕的设计,尽管投入了两年时间开发,却仅仅用于创建架构。我将尝试解释其普遍和具体的设计缺陷,主要侧重于C++开发。理解本文需要Symbian OS编程经验。理解代码示例需要标准的C++和STL的平均水平知识。此外,还需要理解,不仅是移动设备,开发者本身也拥有非常有限的内存和处理能力!
普遍存在的缺陷(按严重程度排序,从最严重到最细微)
复杂性
根据文档,Symbian OS是“一个大型操作系统,包含数百个类和数千个成员函数”。它似乎是有史以来最复杂的操作系统之一。基本的Symbian OS类层次结构包含1201个类。Series 60 SDK增加了293个类,UIQ SDK又增加了705个类!有许多类的接口包含数百个方法,每个类平均方法数少于10个是非常罕见的。没有人能完全理解这种规模的系统。
无法检测设备通用的事物
Symbian OS有一个通用的核心,但系统接口类的用户界面部分却完全不同。然而在大多数情况下,它们应该是一样的!让我们比较一下Series 60和UIQ上的对话框。两者都有一个带有文本标题栏。两者都有“控件元素”,从上到下排列。两者都有关联的命令(在Series 60上的“选项菜单”和UIQ上的“按钮行”)。控件元素呈现给用户以查看和修改文本、数字、日期、时间等。它们在第一平台上可能看起来不同,并由触控笔操作,而在第二平台上由软键操作,但从应用程序的角度来看,它们的行为是相同的。命令如何被用户呈现和选择并不重要,只要它能正确触发相应的函数。对话框的这种通用方法在J2ME中表现得最好。这与Java语言无关。这只是一个好架构与坏架构的例子。
有趣的是,第三方开发者创建了一套头文件,允许Series 60程序在UIQ上编译和运行!OS创建者未能理解统一UI结构的需求,这非常奇怪。它可以扩展(添加新的控件元素、命令等),但不应该被重写。问题不仅在于代码的可重用性(例如,大多数对话框可以完全重用),还在于“开发者的可重用性”。人类很难学习和理解新概念,记住数百个类名和数千个方法签名。
基于错误事实和陈述做出关键决策
这方面最好的例子是Symbian OS中的异常处理。简洁的C++异常机制被放弃,取而代之的是丑陋的“清理栈”,其依据是“C++异常会使编译后的代码体积增大40%”。这种比较方式是:程序分别在启用和禁用异常支持的编译器选项下编译,然后比较体积。但是,使用异常的程序是*设计*来将异常作为安全机制的。当禁用异常处理时,程序体积较小,但不够安全。许多研究表明,在添加了另一种安全机制(例如,检查错误代码)后,编译后的代码不仅恢复到原始大小,而且*源代码*也显著增长。
在放弃C++异常机制后,C++变得蹩脚,并且其他丑陋的东西也随之而来——两阶段构造(参见Stroustrup的书[^]附录E,解释了两阶段构造为何是个坏东西),带有编译器无法检测到的使用限制的T-、R-和C-类,禁止某些情况下的多重继承等,直到语言不再是C++。然后论坛上的“专家”说:“当你开始编写Symbian程序时,你应该忘记一切,从头开始。”这实际上意味着“我们犯了可怕的错误,你无法以清晰便捷的方式编写代码。”如果清理栈在OS中作为异常清理机制实现,那么插入CleanupStack::Push
和CleanupStack::Pop
应该是编译器的职责,而不是开发者的。人类在这种任务上是不可靠的。
让我们看看标准C++编译器在“幕后”生成的代码,假设清理机制是通过清理栈实现的。确保正确清理的经典方法是:每个对象实例都应该是成员,驻留在栈上,或者由*一个*std::auto_ptr
拥有。在以下示例中,Resource
和Aggregate
是持有某些资源(例如文件或分配的内存)的类。它们都有(非内联)构造函数和析构函数,因此编译器负责调用析构函数,并期望构造函数抛出异常。
class Example { Resource one; Resource two; int simple_value; std::auto_ptr<Aggregate> aggregate; public: explicit Example(int data): one("One"), two("Two"), simple_value( 5 ), aggregate( new Aggregate(data) ) { // CODE1 - can throw an exception } void operation() { aggregate->operation(); } };
在幕后,编译器将生成以下代码(我使用C语言作为可移植的汇编语言来解释)
struct Example { Resource one; Resource two; auto_ptr__example aggregate; }; void example__destructor(Example * this) { aggregate__destructor( &aggregate ); resource__destructor( &two ); resource__destructor( &one ); } void example__constructor(Example * this, int data) { resource__constructor( &this->one, "One" ); CleanupStack::Push( &this->one, resource__destructor ); resource__constructor( &this->two, "Two" ); CleanupStack::Push( &this->two, resource__destructor ); // use the knowledge that int has no destructor // and its contructor cannot throw an exception this->simple_value = 5; Aggregate * ag = malloc_or_throw_bad_alloc( sizeof(Aggregate) ); // protect against exception in aggregate__constructor CleanupStack::Push( ag, free ); aggregate__constructor( ag, data ); CleanupStack::PopMemory( ag ); // Use the knowledge, that inline auto_ptr constructor // cannot throw an exception auto_ptr__example__construct( &this->aggregate, ag ); CleanupStack::Push( &this->aggregate, aggregate__destructor ); // CODE1 - can throw an exception CleanupStack::Pop( 3 ); } void example__operation() { aggregate__operation( aggregate.ptr ); }
稍后,如果Example
类在函数中使用,我们可以这样写
void test() { Example ex(1); // CODE2 - can throw an exception if( condition ) return; // CODE3 - can throw an exception }
编译器将生成
void test() { Example ex; example__constructor( &ex, 1 ); CleanupStack::Push( &ex, example__destructor ); // CODE2 - can throw an exception if( condition ) { CleanupStack::Pop( 1 ); example__destructor( &ex, 1 ); return; } // CODE3 - can throw an exception CleanupStack::Pop( 1 ); example__destructor( &ex, 1 ); }
如果我们比较编译器生成的代码与Symbian手动编写的执行相同功能的代码,我们可以看到,生成的代码与每个Symbian开发者日常编写的代码非常相似,但有时*速度更快*!因为部分对象可以有效地存储在栈上或作为类成员,而不是分配在堆上。标准C++的源代码简短且简单,并且保证会清理所有内容!请注意生成的代码中的注释,了解编译器如何智能地利用其对析构函数和构造函数代码的了解(如果它们是inline
或自动生成的)来决定是否应生成特定代码。T、R、C类不再需要了!现代编译器足够智能,只会在实际需要的地方插入清理机制。
请注意,正确的清理并不取决于异常本身是如何实现的。如果由于某些限制(这需要一种动态类型识别形式),无法抛出任意类,那么throw
/catch
参数可以限制为使用int
或特殊的异常类或预定义的类集。牺牲异常类型是坏的,但它是可以容忍的,因为人类仍然可以轻松使用它们,而牺牲自动清理是不可容忍的,因为人类将不得不面对一个他/她不擅长解决的任务。
文档和清晰示例的缺乏
这只是复杂性的后果。但如果示例是真实程序的源代码,情况会更好。演示一百种列表类型的程序不是编程示例。它对于非技术人员(可能是Symbian的经理)来说看起来不错,他们可以运行并玩它。但开发者在源代码中寻找信息时,却找不到任何有用的东西。
静态数据
文档解释了缺乏静态数据的原因:“实现很困难,因为DLL可能驻留在ROM中,因此我们没有存储静态数据的空间。此外,全局变量在OOP中被认为是坏事”。虽然全局变量在OOP中被认为是不好的,但类的静态变量(和模块静态变量)并非如此。事实上,类的静态变量在OOP中非常常见,用于存储类的实例共享的信息。实现不可行的说法也是错误的,但要理解这一点,让我们考虑以下标准C++的代码示例。
class Example { static int state; };
然后(当需要状态时)。
int s = Example::state;
在幕后,编译器会将静态数据添加到数据段。它看起来像这样
struct DataSegment { ... int example_state; };
稍后,为了访问它,编译器将生成
int s = get_data_segment()->example_state;
其中get_data_segment
是一个虚构的函数,返回数据段的地址(在大多数架构上,它存储在处理器特定的寄存器中)。当然,Symbian应用程序必须存储其数据!在Symbian中,所有程序数据都存储在文档中。
class MyDocument : public CAknDocument { ... int example_state; };
然后(当需要状态时)
int s = static_cast<MyDocument *>( CEikonEnv::Static()->EikAppUi()->Document() )->example_state;
令人惊讶的是,从低级角度来看,这两种方法在低级上几乎没有区别!正如传统操作系统会跟踪数据段一样,Symbian OS应用程序管理器会跟踪文档。通过手动将所有静态数据放入文档,Symbian开发者再次使编译器的任务更加繁重!但这次开发者处于更糟糕的境地。考虑以下代码
template<class T> class RequiresStatic { typedef T state_type; ... static state_type state; };
在标准C++中,对于RequiredStatic
的每个已实例化对象,编译器都会将其状态添加到数据段。
struct DataSegment { ... RequiredStatic<int>::state_type required_static_int_state; RequiredStatic<double>::state_type required_static_double_state; RequiredStatic<Color>::state_type required_static_Color_state; };
但Symbian开发者无法做同样的事情,也无法将这些变量添加到MyDocument
!开发者无法知道实例化了哪些类,尤其是在模板位于类库中时。无论如何,为人类完成编译器的任务是一项非常容易出错的工作,尤其是当它需要编写大量代码时。此外,有趣的是,类和模块静态数据的封装被打破了,因为任何能够访问文档的人都可以访问它们。Symbian试图强制执行面向对象,却打破了它!
奇怪的(非技术性)设计目标
Symbian开发者创建的是一个“面向对象”的系统,而不是一个有用的系统。“面向对象”不是“好”的同义词。OOP只是一种方法,开发人员需要掌握它来构建一个更容易构思、编码、文档化和(以后)理解的系统。Symbian OS的复杂性显然是错误应用面向对象方法的一个好指标。
C++标准库和STL
STL基本上是一个由通用容器和算法组成的库,迭代器将它们连接在一起。Symbian开发者决定实现自己的容器库(但他们完全忽略了算法和大多数迭代器)。文档中提到非标准实现的原因是效率。让我们考虑Symbian对字符串、数组和列表的实现。
Symbian中的字符串称为“描述符”。它们是模板,每个描述符可以包含8位或16位字符序列。描述符基本上分为两组——“可修改”和“不可修改”,前者继承自后者。奇怪的是,不可修改的描述符允许完全替换内容(类似于char *
类型的指针语义)。std::string
的值语义使用通常的const
关键字来标记不可修改的对象。我找不到任何可以增加代码效率的地方,可以用不可修改描述符的指针语义。保持值语义将需要类数量减半。
在每个组中,存在不同类型的描述符。可修改描述符的基础描述符是TDes
,派生描述符是TBuf
、TPtr
、HBuf
。不使用virtual
方法,而是使用位域存储派生类类型,并通过表方法调用。这节省了内存,但与virtual
函数相比,执行速度变慢。
HBuf
类型(堆描述符)的操作、内存和时间要求与std::string
大致相同。TPtr
存储指向外部数据的指针,TBuf
将数据存储在内部,非常适合在栈上创建短字符串。它们都是特殊且有用的类,出于效率的需要。最好的决定是将HBuf
重命名为std::string
,并使所有描述符都具有std::string
的接口。这样,至少一部分代码可以在跨平台之间重用,开发者也可以重用他/她对std::string
的知识和经验。值得一提的是,std::string
缺少一个非常有用的功能——“锁定”字符串并返回数据的非const指针(Symbian描述符具有此功能)。将此方法添加到std::string
将使某些代码无法从Symbian OS移植,但这只是一个小改动,而且很容易记住。并且它主要用于调用OS函数的底层代码,因此无论如何都是不可移植的。
Symbian中有大量的数组和缓冲区类。有些数组使用单一的扁平内存块,有些使用多个分段块。它们在不同操作的效率方面与std::vector
和std::list
相似。还有一个循环缓冲区,与std::deque
非常相似。还有专门用于存储描述符和指针的数组类。此外,Symbian还拥有实现单向和双向链表的类。除packed array外,没有Symbian数组或列表比STL容器在速度或内存上具有任何优势。(STL经常使用特定的优化,例如std::vector
可以使用memmove
而不是循环与operator=
来重新分配简单对象。这种优化几乎总是基于type traits机制。)这种packed array将可变大小的对象存储在扁平内存块中。这种数组与STL不太匹配(例如,它无法有效地使用std::sort
进行排序。但就像std::list
提供自己的sort
方法一样,packed array也能做到这一点。)所以最好的决定是支持STL容器,并在需要时添加一个packed array类。
如果认为std::map
对于移动设备来说过于复杂,那么值得思考的是,如果确实需要,开发者会手动实现它。应用程序开发者应该决定是否使用它,而不是平台创建者。平台创建者应该只为应用程序开发者提供良好的工具。
文档-视图架构
Symbian OS以文档-视图架构为荣,每个应用程序都必须包含Application
、Application UI
、Document
和View
类。我们已经看到,文档代替了数据段(static
变量)。它还为应用程序提供了一个接口,以便OS可以调用其方法。但是Application
、Application UI
和View
类是什么?很明显,单个View
不是每个应用程序的一部分——有些应用程序只有一个对话框。或者有些应用程序在后台运行并通过“警告”(消息窗口)进行通信。此外,许多应用程序有多个视图。因此,强制每个应用程序都有一个View
是错误的。Symbian中的Application
类是创建文档的类,并(再次)为应用程序提供接口。Application UI
是什么?Application UI
是(另一个)提供应用程序接口的类,特别是用于处理命令的接口。当几个事物做同一件事时,就是错误的(这也很搞笑)。例如,Application::Save
方法保存文档,而Document::Save
方法被调用,要求程序尝试释放占用的内存。由于唯一需要的是一个应用程序接口,因此最好将其命名为Application
,然后丢弃其他类。如果某个开发者决定在他的特定应用程序中使用文档-视图架构,那就让他去吧。
资源
使用资源通常是一件危险的事情,因为它将代码分成几个文件和几种编程语言。系统应该提供一种将C++代码和资源定义绑定的方法,但这并不容易!当您可以在不更改可执行文件的情况下更改资源时,资源就很有用。但您如何真正实现这一点?如果您向资源添加一个按钮,您如何添加行为?这是不可能的。那么编辑资源就变得非常有限——您可以添加静态标签,可以定义常量(例如将文本翻译成另一种语言或修改滑块的最大值)。但是因为您无法定义资源的部分,您应该复制它的所有结构,然后使用此功能进行翻译会很昂贵。稍后,当结构发生变化时(例如,将对话框拆分成两个标签页),您应该在每次翻译时仔细重复此操作。资源固有的问题是,您必须精确地将代码与资源匹配。看,J2ME程序(在代码中创建所有控件)比Symbian C++程序加上其资源要短几倍。相信我,为控件标识符定义唯一常量只是编译器的又一项任务,而不是开发者的。顺便说一句,资源定义语言是另一种我们应该学习的语言(而且它远非完美)。
TBool
, TAny
TBool
是一个微小的缺陷。如果编译器支持bool
,则不需要它。如果编译器没有内置bool
类型,最好定义bool
、false
、true
,并建议尽快升级编译器。(如此史前时代的编译器肯定在模板、异常处理等方面存在问题。)
TAny
是另一个微小的缺陷,它没有为C++带来任何新东西,它只是void
的同义词。小事,但每个开发者都应该理解这一点并牢记在心。如果某物不是必需的,就扔掉它!每天,开发者的想法应该是“我能从系统中扔掉什么?”,而不是“我能在系统中添加什么?”。TAny
是后一种方法的明显例子。
Series 60特有的缺陷(其中大部分在UIQ中已得到纠正)
模拟器中的错误处理
大多数错误由特殊代码检测。当检测到这些错误时,应尽可能准确地报告它们。例如,程序可以尝试删除菜单中不存在的菜单项。对任何人(除了诺基亚)来说,显而易见的是,应该显示消息“在方法Menu::remove
中找不到菜单项1013”。在调试模式下(尤其是一些更通用的错误,如“数组下标越界”),也需要断在调试器中。但在Series 60模拟器上,此类程序会立即崩溃,并显示一条信息性消息——“程序已关闭,确定”。几乎任何错误都会导致类似的崩溃,并带有这条臭名昭著的消息。
事情不起作用
例如,当您尝试在视图构造期间显示一个警报时,警报不会出现,没有任何错误或解释。迷信?不,只是复杂性的又一个后果。这类问题在Series 60的世界中非常普遍。您是否读过诺基亚论坛?成百上千的人不断问同样的问题,例如“如何显示列表?”,“如何向...添加菜单?”,“在对话框中...不起作用”等等。人们做不到这些简单的事情,因为这些事情就是不起作用,而不是因为人们愚蠢。
两个退出按钮
这很有趣,但是(根据文档)诺基亚在每个Java程序的菜单中都添加了一个“退出”命令(我怀疑这是否属于打破标准,当然标准并未规定“系统不应向应用程序菜单添加随机命令”)。诺基亚明白这会破坏可移植性(文档中提到“开发人员应权衡可移植性和两个退出命令”)。此外,它还故意破坏了可移植性。任何程序员都清楚,在显示菜单之前,系统可以查找菜单中的Command.EXIT
命令。它不这样做。
结论
尽管投入了巨大努力来构建像Symbian这样庞大的系统(带有Series 60或UIQ等UI变体),但其结果远非完美。Symbian OS应该经过相当大的重新设计才能满足其设计目标,尤其是“为用户提供更丰富的移动体验”。如今,大多数开发人员花费时间不是在应用程序特定的任务上,而是与糟糕的系统设计作斗争。虽然Symbian让程序员承担了许多编译器的任务,但这场战斗无疑是失败的。在大多数现代系统中不可能出现的bug,在Symbian应用程序中却安然无恙。但Symbian的创建者不愿承认他们的错误,并继续坚持Symbian非常好。