个人经验:从低级C开发到Java编程



这篇文章反映了作者的个人经验,他是一位狂热的微控制器程序员,经过多年的C(和C ++)微控制器开发经验之后,他有机会参加一个大型Java项目,为运行Android的电视机顶盒开发软件。 在这个项目期间,我能够收集有关Java和C / C ++语言之间有趣差异的注释,评估编写程序的不同方法。 本文并不伪装成参考;也没有研究Java程序的效率和生产力。 而是个人观察的集合。 除非另有说明,否则这是Java SE 7版本。

语法差异和控制结构


简而言之-差异很小,语法非常相似。 代码块也由一对花括号{}组成。 标识符的编译规则与C / C ++相同。 关键字列表与C / C ++中的几乎相同。 内置数据类型-类似于C / C ++中的数据类型。 数组-全部都用方括号声明。

控件构造if-else,while,do-while,for开关也几乎完全相同。 值得注意的是,在Java中,有C程序员熟悉的标签(强烈建议不要将这些标签与goto关键字一起使用)。 但是,Java排除了使用goto切换到标签的可能性。 标签只能用于退出嵌套循环:

outer: for (int i = 0; i < 5; i++) { inner: for (int j = 0; j < 5; j++) { if (i == 2) break inner; if (i == 3) continue outer; } } 

为了提高Java程序的可读性,添加了一个有趣的机会,用下划线将长数字分开:

 int value1 = 1_500_000; long value2 = 0xAA_BB_CC_DD; 

从外观上看,Java程序与熟悉的C程序没有太大区别,主要的视觉区别是Java不允许源代码中“自由”放置的函数,变量,新类型(结构)的定义,常量等。 Java是一种面向对象的语言,因此所有程序实体都必须属于某个类。 另一个重要的区别是缺少预处理器。 这两个差异将在下面更详细地描述。

C语言中的对象方法


当我们用C编写大型程序时,我们基本上必须使用对象。 对象的作用是通过描述“现实世界”的某些本质的结构来执行的:

 //   – «» struct Data { int field; char *str; /* ... */ }; 

在C语言中也有处理“对象”-结构-功能的方法。 但是,功能实际上并没有与数据合并。 是的,它们通常放置在一个文件中,但是每次有必要将指向要处理的对象的指针传递到“典型”函数中时:

 int process(struct Data *ptr, int arg1, const char *arg2) { /* ... */ return result_code; } 

您只能在分配内存以存储后使用“对象”:

 Data *data = malloc(sizeof(Data)); 

在C程序中,通常定义一个函数,该函数负责“对象”在首次使用之前的初始化:

 void init(struct Data *data) { data->field = 1541; data->str = NULL; } 

那么,C语言中“对象”的生命周期通常是这样的:

 /*    "" */ struct Data *data = malloc(sizeof(Data)); /*  "" */ init(data); /*   "" */ process(data, 0, "string"); /*  ,  ""     . */ free(data); 

现在我们列出程序员在“对象”的生命周期中可能犯的运行时错误:

  1. 忘记为“对象”分配内存
  2. 指定错误的分配内存量
  3. 忘记初始化“对象”
  4. 使用对象后忘记释放内存

由于编译器未检测到这些错误并在程序运行期间出现这些错误,因此检测这些错误可能非常困难。 此外,它们的影响可能非常多样,并且会影响程序的其他变量和“对象”。

Java对象方法


面对面向对象的编程OOP,您可能听说过OOP鲸鱼之一-封装。 在Java中,与C不同,数据和用于处理它们的方法被组合在一起,成为“真正的”对象。 就OOP而言,这称为封装。 类是对对象的描述,C中类的最相似类是使用typedef结构定义新类型。 用Java术语来说,属于类的那些函数称为方法。

 //   class Entity { public int field; //   public String str; //   //  public int process(int arg1, String arg2) { /* ... */ return resultCode; } //  public Entity() { field = 1541; str = "value"; } } 

Java语言的意识形态基于“一切都是对象”的陈述。 因此,Java禁止与类分开创建方法(函数)和数据字段(变量)两者,这并不奇怪。 甚至程序启动时所熟悉的main()方法也必须属于这些类之一。

Java中的类定义类似于C中的结构声明。通过描述一个类,您无需在内存中创建任何内容。 此类的对象在由新操作员创建时出现。 用Java创建对象类似于用C语言分配内存的方法,但是与后者不同,在对象创建过程中会自动调用一种特殊方法-对象的构造函数。 构造函数承担对象的初始初始化的角色-与前面讨论的init()函数类似。 构造函数的名称必须与类的名称匹配。 构造函数无法返回值。

Java程序中对象的生命周期如下:

 //   (   ,  ) Entity entity = new Entity(); //    entity.process(123, "argument"); 

请注意,Java程序中可能发生的错误的数量比C程序中的要少得多。 C程序的情况正在根本改变:

  1. Java中没有sizeof()运算符。 Java编译器本身会计算存储对象的内存量。 因此,不可能指定错误的选择大小。
  2. 对象的初始化在创建时发生。 不可能忘记初始化。
  3. 对象占用的内存不需要释放;垃圾收集器可以完成这项工作。 使用完之后,要忘记删除一个对象是不可能的-“内存泄漏”效应的可能性较小。

因此,Java中的所有内容都是一类或另一类的对象。 例外是已添加到语言中以改善性能和内存消耗的原语。 有关原语的更多信息,请参见下文。

内存和垃圾收集器


Java保留了程序员C / C ++熟悉的堆和栈概念。 使用new运算符创建对象时,用于存储对象的内存是从堆中借用的。 但是,如果创建的对象不是另一个对象的一部分,则将指向对象的链接(链接是指针的类似物)放置在堆栈上。 在堆上存储对象的“实体”,并在堆栈上存储局部变量:对对象和原始类型的引用。 如果堆在程序执行期间存在并且可用于程序的所有线程,则堆栈属于该方法,并且仅在执行期间存在,并且程序的其他线程也无法访问该堆栈。

Java是不必要的,甚至更是如此-您无法手动释放对象占用的内存。 这项工作由垃圾收集器以自动模式完成。 运行时通过跟踪对象之间的链接来监视是否有可能从程序的当前位置访问堆中的每个对象。 如果不是,则将这种对象识别为“垃圾”,并成为删除对象。

重要的是要注意,删除本身不会在“不再需要”对象的时候发生-垃圾收集器决定删除,并且删除可以根据需要尽可能延迟,直到程序终止。

当然,垃圾收集器的工作需要处理器开销。 但是作为回报,他使程序员免于因使用“对象”结束后需要释放内存而感到头疼。 实际上,我们在需要时“拿走”内存并使用它,而不认为我们需要自己释放内存。

说到局部变量,我们应该回顾一下Java对其进行初始化的方法。 如果在C / C ++中,一个未初始化的局部变量包含一个随机值,那么Java编译器将完全不允许将其保留为未初始化:

 int i; //  . System.out.println("" + i); //  ! 

链接-替换指针


Java没有指针;因此,Java程序员无法犯下使用指针时发生的许多错误之一。 创建对象时,将获得指向该对象的链接:

 //  entity –  . Entity entity = new Entity(); 

在C语言中,程序员可以选择:如何将结构传递给函数。 您可以按值传递:

 //    . int func(Data data);    –   : //    . void process(Data *data); 

值传递保证了该函数不会更改结构中的数据,但是在性能方面无效-在调用该函数时,将创建该结构的副本。 传递指针的效率更高:实际上,结构所在的内存中的地址已传递给函数。

在Java中,只有一种方法可以通过引用将对象传递给方法。 Java中的引用传递类似于C中的指针传递:
  • 不会复制(克隆)内存,
  • 实际上,该对象的位置的地址被发送。

但是,与C语言指针不同,Java链接不能递增/递减。 使用Java中的链接“遍历”数组的元素将不起作用。 链接可以做的就是给它一个不同的值。

当然,没有这样的指针会减少可能的错误的数量,但是,空指针的类似物仍保留在语言中-由null关键字表示的空引用。

空引用是Java程序员的头疼问题,因为 强制在使用对象引用之前将其检查为null或处理NullPointerException异常。 如果不这样做,程序将崩溃。

因此,Java中的所有对象都是通过链接传递的。 原始数据类型(int,long,char ...)按值传递(有关原始的更多信息,请参见下文)。

Java链接功能


通过链接访问程序中的任何对象-这显然对性能有积极影响,但可能会使新手感到惊讶:

 //  ,   entity1   . Entity entity1 = new Entity(); entity1.field = 123; //   entity2,     entity1. //    !   ! Entity entity2 = entity1; //   entity1  entity2         . entity2.field = 777; //  entity1.field  777. System.out.println(entity1.field); 

方法参数和返回值-一切都通过链接传递。 除了优点之外,与C / C ++语言相比,还有一个缺点,在这种语言中,我们可以使用const限定符显式禁止函数更改通过指针传递的值:

 void func(const struct Data* data) { //  ! //    ,    ! data->field = 0; } 

也就是说,C语言允许您在编译阶段跟踪此错误。 Java也具有const关键字,但是保留给将来的版本,并且目前根本不使用。 在某种程度上,要求final关键字履行其职责。 但是,它不能保护传递给方法的对象不受更改:

 public class Main { void func(final Entity data) { //    . //    final,    . data.field = 0; } } 

关键是在这种情况下,final关键字将应用于链接,而不应用于链接所指向的对象。 如果将final应用于基元,则编译器将按预期方式运行:

 void func(final int value) { //    . value = 0; } 

Java链接与C ++语言链接非常相​​似。

Java原语


每个Java对象除数据字段外,还包含支持信息。 例如,如果我们要在单独的字节中进行操作,并且每个字节由一个对象表示,那么在字节数组的情况下,内存开销可能会超出可用大小很多倍。
为了使Java在上述情况下保持足够的效率,已在语言中添加了对基本类型(基本类型)的支持。
原始的检视位深C中可能的类似物
字节整数8烧焦
短的16短的
烧焦16wchar_t
整型32int(长整数)
64
飘浮浮点数32飘浮
双倍64双倍
布尔值逻辑上--int(C89)/ bool(C99)

所有原语都具有C语言的类似物,但是C标准不能确定整数类型的确切大小;相反,该类型可以存储的值的范围是固定的。 通常,程序员希望为不同的机器确保相同的位深度,这会导致程序中出现uint32_t之类的类型,尽管所有库函数都需要int类型的参数。
这个事实不能归因于语言的优势。

Java整数基元与C不同,具有固定的位深度。 因此,您不必担心正在运行Java程序的计算机的实际位深以及字节顺序(“网络”或“英特尔”)。 这一事实有助于实现“一次编写,随处可见”的原则。

另外,在Java中,所有整数基元都被签名(该语言缺少unsigned关键字)。 这消除了在C固有的单个表达式中使用有符号和无符号变量的困难。

总之,Java中多字节原语中的字节顺序是固定的(低字节在低地址,低位在前,反向顺序)。

在Java中使用原语实现操作的缺点包括以下事实:与C / C ++程序一样,在此可能发生位网格溢出,并且不会引发异常:

 int i1 = 2_147_483_640; int i2 = 2_147_483_640; int r = (i1 + i2); // r = -16 

因此,Java中的数据由两种实体表示:对象和基元。 基元违反了“一切都是对象”的概念,但是在某些情况下,它们过于有效以至于无法使用它们。

传承


继承是您可能听说过的另一种OOP鲸鱼。 如果您简短地回答“为什么根本需要继承”这个问题,那么答案将是“代码重用”。

假设您使用C进行编程,并且有一个编写良好且经过调试的“类”-用于处理它的结构和函数。 接下来,需要创建一个类似的“类”,但是具有增强的功能,并且仍然需要基本的“类”。 对于C语言,只有一种方法可以解决此问题-组合。 它是关于创建一个新的扩展结构-“类”,该结构应包含一个指向基本“类”结构的指针:

 struct Base { int field1; char *field2; }; void baseMethod(struct Base *obj, int arg); struct Extended { struct Base *base; int auxField; }; void extendedMethod(struct Extended *obj, int arg) { baseMethod(obj->base, 123); /* ... */ } 

Java作为一种面向对象的语言,使您可以使用继承机制来扩展现有类的功能:

 //   class Base { protected int baseField; private int hidden; public void baseMethod() { } } //   -   . class Extended extends Base { public void extendedMethod() { //    public  protected     . baseField = 123; baseMethod(); // !   private  ! hidden = 123; } } 

应当指出,Java绝不禁止使用组合作为扩展已编写类功能的一种方式。 而且,在许多情况下,组合优于继承。

由于继承,Java中的类按层次结构排列,每个类必然具有一个且只有一个“父”,并且可以具有任意数量的“子代”。 与C ++不同,Java中的类不能从多个父类继承(这解决了“钻石继承”的问题)。

在继承过程中,派生类将其基类的所有公共和受保护字段和方法以及其基类的基类移到其位置,依此类推。

继承层次结构的顶部是所有Java类的通用祖先-Object类,这是唯一一个没有父类的类。

动态类型识别


Java语言的关键点之一是对动态类型标识(RTTI)的支持。 简而言之,RTTI允许您替换需要引用基数的派生类的对象:

 //     Base link; //         link = new Extended(); 

在运行时拥有链接,您可以使用instanceof运算符确定链接所引用的对象的真实类型:

 if (link instanceof Base) { // false } else if (link instanceof Extended) { // true } 

方法重写


重新定义方法或功能意味着在程序执行阶段替换其身体。 C程序员意识到语言在程序执行过程中改变功能行为的能力。 这是关于使用函数指针的。 例如,您可以在结构的结构中包含指向函数的指针,并为该指针分配各种功能以更改此结构的数据处理算法:

 struct Object { //   . void (*process)(struct Object *); int data; }; void divideByTwo(struct Object *obj) { obj->data = obj->data / 2; } void square(struct Object *obj) { obj->data = obj->data * obj->data; } struct Object obj; obj.data = 123; obj.process = divideByTwo; obj.process(&obj); // 123 / 2 = 61 obj.process = square; obj.process(&obj); // 61 * 61 = 3721 

与其他OOP语言一样,在Java中,覆盖方法与继承有着千丝万缕的联系。 派生类可以访问基类的公共方法和受保护的方法。 除了可以调用它们的事实外,您还可以更改基类方法之一的行为,而无需更改其签名。 为此,在派生类中定义一个具有完全相同签名的方法就足够了:

 //   -   . class Extended extends Base { //  . public void method() { /* ... */ } //     ! // E      . //     . public void method(int i) { /* ... */ } } 

签名(方法名称,返回值,参数)必须完全匹配,这一点非常重要。 如果方法名称匹配,并且参数不同,则该方法将重载,有关以下内容的更多信息。

多态性


像封装和继承一样,第三种OOP鲸鱼-多态性-在面向过程的C语言中也具有某种类似物。

假设我们要使用几种结构的“类”来执行相同类型的操作,并且执行此操作的功能必须是通用的-必须“能够”将任何“类”用作参数。 可能的解决方案如下:

  /*   */ enum Ids { ID_A, ID_B }; struct ClassA { int id; /* ... */ } void aInit(ClassA obj) { obj->id = ID_A; } struct ClassB { int id; /* ... */ } void bInit(ClassB obj) { obj->id = ID_B; } /* klass -   ClassA, ClassB, ... */ void commonFunc(void *klass) { /*   */ int id = (int *)klass; switch (id) { case ID_A: ClassA *obj = (ClassA *) klass; /* ... */ break; case ID_B: ClassB *obj = (ClassB *) klass; /* ... */ break; } /* ... */ } 

解决方案看起来很麻烦,但是却达到了目标-通用函数commonFunc()接受任何“类”的“对象”作为参数。前提条件是第一个字段中的“类”结构必须包含一个标识符,通过该标识符可以确定对象的实际“类”。由于使用了类型为“ void *”的参数,因此可以实现这种解决方案。但是,任何类型的指针都可以传递给这样的函数,例如“ int *”。这不会导致编译错误,但是在运行时程序将无法正常运行。

现在,让我们看看Java中的多态性(但是,与任何其他OOP语言一样)。假设我们有许多类应该通过某种方法以相同的方式处理。与上面介绍的C语言解决方案不同,此多态方法必须包含在给定集合的所有类中,并且其所有版本都必须具有相同的签名。

 class A { public void method() {/* ... */} } class B { public void method() {/* ... */} } class C { public void method() {/* ... */} } 

接下来,您需要强制编译器精确调用属于相应类的方法的版本。

 void executor(_set_of_class_ klass) { klass.method(); } 

也就是说,可以在程序中任何位置的executor()方法必须能够与集合(A,B或C)中的任何类一起使用。我们必须以某种方式“告诉”编译器_set_of_class_表示我们的许多类。在这里继承很方便-必须从某个基类的集合派生类中创建所有类,这些基类将包含多态方法:

 abstract class Base { abstract public void method(); } class A extends Base { public void method() {/* ... */} } class B extends Base { public void method() {/* ... */} } class C extends Base { public void method() {/* ... */} }   executor()   : void executor(Base klass) { klass.method(); } 

现在,任何可以继承Base的类(由于使用动态类型标识)都可以作为参数传递给它:

 executor(new A()); executor(new B()); executor(new C()); 

根据将哪个类对象作为参数传递,将调用属于该类的方法。

abstract关键字允许您排除方法的主体(根据OOP将其抽象)。实际上,我们告诉编译器必须在从其派生的类中重写此方法。如果不是这种情况,则会发生编译错误。包含至少一个抽象方法的类也称为抽象。编译器要求还使用关键字abstract标记此类。

Java项目结构


在Java中,所有源文件都具有扩展名* .java。* .h头文件和函数或类的原型均缺失。每个Java源文件必须至少包含一个类。班级的名称习惯上以大写字母开头。

几个带有源代码的文件可以合并到一个包中。为此,必须满足以下条件:
  1. 带有源代码的文件必须在文件系统中的同一目录中。
  2. 该目录的名称必须与软件包的名称匹配。
  3. 在每个源文件的开头,应指出该文件所属的软件包,例如:

 package com.company.pkg; 

为了确保包名称在全球范围内的唯一性,建议使用公司的“倒置”域名。但是,这不是必需的,并且可以在本地项目中使用任何名称。

还建议您指定小写的程序包名称。因此可以轻松地将它们与类名区分开。

隐藏执行


封装的另一个方面是接口和实现的分离。如果程序的外部部分(模块或类的外部)可访问该接口,则隐藏该实现。在文献中,当内部实现从外部“不可见”时,通常会画出一个黑匣子类比,但输入到该方框的输入中以及发出的东西是“可见的”。

在C语言中,隐藏实现是在模块内部执行的,使用static关键字标记从外部不可见的功能。构成模块接口的功能原型放置在头文件中。 C语言中的模块表示一对:扩展名为* .c的源文件和扩展名为* .h的头。

Java也具有static关键字,但它不会从外部影响方法或字段的“可见性”。要控制“可见性”,有3个访问修饰符:私有,受保护,公共。

标记为私有的类的字段和方法仅在其内部可用。类后代也可以访问受保护的字段和方法。public修饰符意味着可以从类外部访问被标记的元素,也就是说,它是接口的一部分。也可能没有修饰符,在这种情况下,对类元素的访问受到类所在的包的限制。

建议在编写类时,首先将类的所有字段标记为私有,并根据需要扩展访问权限。

方法重载


C标准库令人讨厌的功能之一是存在整个函数库,它们执行的功能基本上相同,但参数类型不同,例如:fabs(),fabsf(),fabsl()-用于获取double,float和long的绝对值的函数双重类型。

Java(以及C ++)支持方法重载机制-类中可能有多个名称完全相同,但参数类型和数量不同的方法。根据参数的数量及其类型,编译器将选择方法本身的必要版本-这非常方便并且提高了程序的可读性。

在Java中,与C ++不同,运算符不能重载。例外是“ +”和“ + =”运算符,它们最初在String字符串中重载。

Java中的字符和字符串


在C语言中,您必须使用由第一个字符的指针表示的空终端字符串:

 char *str; //  ASCII  wchar_t *strw; //   ""  

这些行必须以空字符结尾。如果不小心“擦除”了字符串,则该字符串将被视为内存中的字节序列,直到第一个空字符为止。也就是说,如果在该行之后将其他程序变量放置在内存中,则在修改了这种损坏的行之后,它们的值可能(并且很可能会)失真。

当然,C程序员不是必须使用经典的空终端字符串,而是应用第三方实现,但是在此必须牢记,标准库中的所有函数都需要空终端字符串作为参数。另外,C标准没有定义所使用的编码,这一点也应由程序员控制。

在Java中,原始的char类型(以及Character包装器,关于下面的包装器)根据Unicode标准表示单个字符。分别使用UTF-16编码,一个字符在内存中占用2个字节,这使您可以编码当前使用的语言的几乎所有字符。

可以通过Unicode指定字符:

 char ch1 = '\u20BD'; 

如果字符的Unicode超过char的最大值216,则此类字符必须用int表示。在字符串中,它将占用2个16位字符,但是同样,很少使用代码超过216的字符。

Java字符串由内置的String类实现,并存储16位char字符。 String类包含使用字符串可能需要的全部或几乎所有内容。无需考虑行必须以零结尾的事实;这里不可能无意识地“擦除”该零终止字符或在行外访问内存。通常,在Java中使用字符串时,程序员不会考虑字符串如何存储在内存中。

如上所述,Java不允许运算符重载(如在C ++中一样),但是String类是一个例外-仅在这种情况下,行合并运算符“ +”和“ + =”最初才被重载。

 String str1 = "Hello, " + "World!"; String str2 = "Hello, "; str2 += "World!"; 

值得注意的是,Java中的字符串是不可变的-创建后,它们不允许更改。例如,当我们尝试更改行时,如下所示:

 String str = "Hello, World!"; str.toUpperCase(); System.out.println(str); //   "Hello, World!" 

因此,原始字符串实际上并未更改。相反,将创建原始字符串的修改后的副本,该副本又是不可变的:

 String str = "Hello, World!"; String str2 = str.toUpperCase(); System.out.println(str2); //   "HELLO, WORLD!" 

因此,实际上字符串的每次更改都会导致创建新对象(实际上,在合并字符串的情况下,编译器可以优化代码并使用StringBuilder类,这将在后面讨论)。

碰巧该程序经常需要更改同一行。在这种情况下,为了优化程序的速度和内存消耗,可以防止创建新的行对象。为此,应使用StringBuilder类:

 String sourceString = "Hello, World!"; StringBuilder builder = new StringBuilder(sourceString); builder.setCharAt(4, '0'); builder.setCharAt(8, '0'); builder.append("!!"); String changedString = builder.toString(); System.out.println(changedString); //   "Hell0, W0rld!!!" 

另外,值得一提的是字符串的比较。新手Java程序员的一个典型错误是使用“ ==”运算符比较字符串:

 //    "Yes" // ! if (usersInput == "Yes") { //    } 

这样的代码在形式上不会包含编译阶段的错误或运行时错误,但其工作原理可能与预期的不同。由于所有对象和字符串(包括Java中的字符串)均由链接表示,因此与“ ==”运算符的比较将给出链接的比较,而不是对象的值。也就是说,仅当2个链接确实引用同一行时,结果才为true。如果字符串是内存中的不同对象,并且您需要比较它们的内容,则需要使用equals()方法:

 if (usersInput.equals("Yes")) { //    } 

最令人惊讶的是,在某些情况下,使用“ ==”运算符进行比较可以正常进行:

 String someString = "abc", anotherString = "abc"; //   "true": System.out.println(someString == anotherString); 

这是因为,实际上,someString和anotherString引用内存中的同一对象。编译器将相同的字符串文字放入字符串池中-发生所谓的实习。然后,每次相同的字符串文字出现在程序中时,都会使用指向池中字符串的链接。由于字符串的不变性,因此可以进行字符串的嵌入。

尽管仅通过equals()方法可以比较字符串的内容,但是在Java中,可以在切换用例的构造中正确使用字符串(从Java 7开始):

 String str = new String(); // ... switch (str) { case "string_value_1": // ... break; case "string_value_2": // ... break; } 

奇怪的是,任何Java对象都可以转换为字符串。在基类中为Object类的所有类定义了相应的toString()方法。

错误处理方法


使用C进行编程时,您可能会想到以下错误处理方法。库的每个函数都返回一个int类型。如果函数成功,则结果为0。如果结果为非零,则表明错误。通常,错误代码通过函数返回的值传递。由于该函数只能返回一个值,并且已被错误代码占用,因此该函数的实际结果必须通过参数作为指针返回,例如,如下所示:

 int function(struct Data **result, const char *arg) { int errorCode; /* ... */ return errorCode; } 

顺便说一句,这是在C程序中有必要使用指向指针的指针时的情况之一。

有时他们使用不同的方法。该函数不返回错误代码,而是直接返回其执行结果,通常以指针的形式。用空指针指示错误情况。然后,该库通常包含一个单独的函数,该函数返回最后一个错误的代码:

 struct Data* function(const char *arg); int getLastError(); 

一种或另一种方式,当使用C进行编程时,完成“有用”工作的代码与负责处理错误的代码相互交错,这显然使程序不易于阅读。

如果愿意,在Java中,可以使用上述方法,但是在这里,您可以采用一种完全不同的方法来处理错误-异常处理(但是,与C ++一样)。异常处理的优点在于,在这种情况下,“有用的”代码与负责处理错误和突发事件的代码在逻辑上是彼此分离的。

这是通过try-catch构造实现的:“有用的”代码位于try部分中,而错误处理代码位于catch部分中。

 //       try (FileReader reader = new FileReader("path\\to\\file.txt")) { //    -   . while (reader.read() != -1){ // ... } } catch (IOException ex) { //     } 

在某些情况下,无法在发生错误的地方正确处理错误。在这种情况下,方法签名中会出现一个指示,表明该方法可能导致这种类型的异常:

 public void func() throws Exception { // ... } 

现在,对该方法的调用必须必须放在try-catch块中,或者也必须将调用方法标记为可以引发此异常。

缺乏预处理器


无论C / C ++程序员熟悉的预处理器多么方便,Java语言中都没有它。Java开发人员可能决定仅将其用于确保程序的可移植性,并且由于Java在任何地方(几乎)运行,因此不需要预处理器。

您可以使用静态标志字段来弥补预处理器的不足,并在必要时在程序中检查其值。

如果我们在谈论测试的组织,那么可以将批注与反射(reflect)结合使用。

数组也是一个对象。


在C语言中使用数组时,索引出口超出数组边界是一个非常隐蔽的错误。编译器将不会以任何方式报告它,并且在执行期间程序不会因相应的消息而停止:

 int array[5]; array[6] = 666; 

程序很可能会继续执行,但是上例中位于数组数组之后的变量的值将变形。调试此类错误可能并不容易。

在Java中,保护程序员免受此类难以诊断的错误的影响。当您尝试超出数组的范围时,将引发ArrayIndexOutOfBoundsException。如果未使用try-catch构造对异常捕获进行编程,则程序将崩溃,并且相应的消息将发送到标准错误流,该消息指示带有源代码的文件以及超出数组的行号。即,对这种错误的诊断变得无关紧要。

因为Java中的数组由一个对象表示,所以Java程序的这种行为成为可能。无法调整Java数组的大小;在分配内存时会对其大小进行硬编码。在运行时,获取数组的大小就像给梨脱壳一样容易:

 int[] array = new int[10]; int arraySize = array.length; // 10 

如果我们谈论多维数组,那么与C语言相比,Java提供了一个有趣的机会来组织“梯形”数组。对于二维数组,每行的大小可能与其他行不同:

 int[][] array = new int[10][]; for (int i = 0; i < array.length; i++) { array[i] = new int[i + 1]; } 

与在C语言中一样,数组的元素一一位于内存中,因此对数组的访问被认为是最有效的。如果您需要执行元素的插入/删除操作,或者创建更复杂的数据结构,则需要使用集合,例如集合(Set),列表(List),地图(Map)。

由于缺少指针并且无法增加链接,因此可以使用索引访问数组的元素。

馆藏


通常,数组的功能是不够的-那么您需要使用动态数据结构。由于标准C库不包含动态数据结构的现成实现,因此您必须以源代码或库的形式使用该实现。

与C不同,标准Java库包含一组丰富的动态数据结构或集合的实现,以Java表示。所有集合都分为3大类:列表,集合和地图。

列表-动态数组-允许您添加/删除项目。许多元素不能确保添加元素​​的顺序,但是可以保证没有重复的元素。卡或关联数组使用键值对操作,并且键值是唯一的-卡中不能有两对具有相同键的键对。

对于列表,集合和映射,有许多实现,每种实现都针对特定操作进行了优化。例如,列表是由ArrayList和LinkedList类实现的,其中ArrayList在访问任意元素时提供更好的性能,而LinkedList在列表中间插入/删除元素时效率更高。

只能将完整的Java对象存储在集合中(实际上是对对象的引用),因此无法直接创建基元的集合(int,char,byte等)。在这种情况下,应使用适当的包装器类:
原始的包装类
字节字节数
短的短的
烧焦性格
整型整数
长的
飘浮浮点数
双倍双倍
布尔值布尔型

幸运的是,使用Java进行编程时,无需遵循原始类型及其“包装器”的确切重合。如果该方法接收到例如Integer类型的参数,则可以将其传递给int类型。反之亦然,如果需要int类型,则可以安全地使用Integer。这归功于Java内置的用于打包/解包原语的机制。

在令人不快的时刻中,应该提到的是,标准Java库包含旧的收集类,这些旧的收集类在Java的第一个版本中未成功实现,并且不应在新程序中使用。这些是类Enumeration,Vector,Stack,Dictionary,Hashtable,Properties。

概论


集合通常用作通用数据类型。在这种情况下,归纳的实质是我们指定集合的​​主要类型,例如ArrayList,并在尖括号中指定参数类型,在这种情况下,该参数类型确定列表中存储的元素的类型:

 List<Integer> list = new ArrayList<Integer>(); 

这允许编译器跟踪添加指定类型参数以外类型的对象的尝试:

 List<Integer> list = new ArrayList<Integer>(); //  ! list.add("First"); 

在程序执行期间删除类型参数非常重要,例如,该类的对象之间没有区别
 ArrayList <整数> 
和类对象
 ArrayList <String>。 
结果,无法在程序执行期间找出集合元素的类型:

 public boolean containsInteger(List list) { //  ! if (list instanceof List<Integer>) { return true; } return false; } 

部分解决方案可能是以下方法:采用集合的第一个元素并确定其类型:

 public boolean containsInteger(List list) { if (!list.isEmpty() && list.get(0) instanceof Integer) { return true; } return false; } 

但是,如果列表为空,则此方法将不起作用。

在这方面,Java概括明显不如C ++概括。Java归纳实际上可以在编译阶段“切断”某些潜在的错误。

遍历数组或集合的所有元素


使用C进行编程时,通常必须遍历数组的所有元素:

 for (int i = 0; i < SIZE; i++) { /* ... */ } 

要使错误更简单,只需指定错误的SIZE数组大小,或使用“ <=”代替“ <”即可。

在Java中,除了for语句的“常规”形式外,还有一种用于迭代数组或集合的所有元素的形式(在其他语言中通常称为foreach):

 List<Integer> list = new ArrayList<>(); // ... for (Integer i : list) { // ... } 

在这里,我们保证对列表的所有元素进行迭代,消除了for语句的“常规”形式所固有的错误。

杂项收藏


由于所有对象都是从根对象继承的,因此Java有一个有趣的机会来创建具有各种实际元素类型的列表:

 List list = new ArrayList<>(); list.add(new String("First")); list.add(new Integer(2)); list.add(new Double(3.0));         instanceof: for (Object o : list) { if (o instanceof String) { // ... } else if (o instanceof Integer) { // ... } else if (o instanceof Double) { // ... } } 

转账


比较C / C ++和Java,不可能不注意到Java中实现了更多的功能枚举。这里枚举是一个完整的类,枚举元素是此类的对象。这允许一个枚举元素将多个任何类型的字段设置为对应关系:

 enum Colors { //     -   . RED ((byte)0xFF, (byte)0x00, (byte)0x00), GREEN ((byte)0x00, (byte)0xFF, (byte)0x00), BLUE ((byte)0x00, (byte)0x00, (byte)0xFF), WHITE ((byte)0xFF, (byte)0xFF, (byte)0xFF), BLACK ((byte)0x00, (byte)0x00, (byte)0x00); //  . private byte r, g, b; //  . private Colors(byte r, byte g, byte b) { this.r = r; this.g = g; this.b = b; } //  . public double getLuma() { return 0.2126 * r + 0.7152 * g + 0.0722 * b; } } 

作为完整类,枚举可以具有方法,并且使用私有构造函数,可以设置单个枚举元素的字段值。

通常有机会获得枚举元素的字符串表示形式,序列号以及所有元素的数组:

 Colors color = Colors.BLACK; String str = color.toString(); // "BLACK" int i = color.ordinal(); // 4 Colors[] array = Colors.values(); // [RED, GREEN, BLUE, WHITE, BLACK] 

反之亦然-通过字符串表示形式,您可以获取枚举元素,并调用其方法:

 Colors red = Colors.valueOf("RED"); // Colors.RED Double redLuma = red.getLuma(); // 0.2126 * 255 

自然地,枚举可用于切换用例构造。

结论


当然,C和Java语言旨在解决完全不同的问题。但是,如果仍然比较这两种语言的软件开发过程,那么,根据作者的主观印象,Java语言在编写程序的便利性和速度方面将大大超过C语言。开发环境(IDE)在提供便利方面起着重要作用。作者使用IntelliJ IDEA IDE。使用Java进行编程时,您不必经常“害怕”要犯错误-开发环境通常会告诉您需要修复的内容,有时它会为您解决。如果发生运行时错误,那么错误的类型及其在源代码中的位置始终会在日志中指出-与此类错误的斗争变得无关紧要。 C程序员无需费力就可以切换到Java,而这一切都是因为该语言的语法略有变化。

如果对读者而言,这种经验很有趣,那么在下一篇文章中,我们将讨论使用JNI机制(从Java应用程序运行本机C / C ++代码)的经验。当您要控制屏幕分辨率,蓝牙模块,或者在其他情况下,Android服务和管理器的功能还不够时,JNI机制是必不可少的。

Source: https://habr.com/ru/post/zh-CN412775/


All Articles