上篇文章:【HarmomyOS6】ArkTS入门——01-基本知识-CSDN博客

接下来我们讲一下函数和类。

一、函数

在HarmonyOS应用开发中,函数是构建应用逻辑的核心元素之一。

1.1 函数的声明

函数是执行特定任务的代码块。函数声明包含函数名、参数列表、返回类型和函数体。

以下示例是一个简单的函数和对应的说明:

  1. 参数类型标注:first: string, second: string显式声明参数为字符串类型。
  2. 返回值类型:: string指定函数返回值为字符串类型。
function formatName(first: string, second: string): string {
  let fullname: string = `${firstName} ${lastName)`;
  return fullname;
}

特点:

  • 在函数声明中,参数类型标注是强制的
  • 若参数为可选参数,那么允许在调用函数时省略该参数。函数的最后一个参数可以是rest参数。

1.2 可选参数

可选参数的格式:

function hello(name?: type) {
  //
}

示例:

function greet(userName?: string): string {
  if (userName) {
    return `Hello, ${userName}!`;
  }
  return 'Hello, Guest!';
}

可选参数还有另一种实现形式:设置参数默认值。

如果在函数调用中这个参数被省略了,则会使用此参数的默认值作为实参。

function multiply(n: number, coeff: number = 2): number {
  return n * coeff;
}
multiply(2);  // 返回2*2
multiply(2, 3); // 返回2*3

1.3 rest参数

rest参数用于处理不定数量的参数,在事件处理和数据处理中很常见。想象您在设计一个购物车的计算总价功能,用户可能买1件商品,也可能买10件。这时候就需要rest参数。

函数的最后一个参数可以是rest参数,格式为...restName: Type[]。rest参数允许函数接收一个不定长数组,用于处理不定数量的参数输入。

function sum(...numbers: number[]): number {
  let res = 0;
  for (let n of numbers) {
    res += n;
  }
  return res;
}

sum(); // 返回0
sum(1, 2, 3); // 返回6

1.4 返回类型

函数执行完成后,通常要给我们一个结果。

返回类型告诉调用者,函数会返回什么“东西”。这极大地增强了代码的可读性和可靠性。

如果可以从函数体内推断出函数返回类型,则可在函数声明中省略标注返回类型。

// 显式指定返回类型
function foo(): string { return 'foo'; }

// 推断返回类型为string
function goo() { return 'goo'; }

不需要返回值的函数的返回类型可以显式指定为void或省略标注。这类函数不需要返回语句。

以下示例中两种函数声明方式都是有效的:

function hi1() { console.info('hi'); }
function hi2(): void { console.info('hi'); }

1.5 函数的作用域(变量/实例的可见范围)

作用域决定了变量在哪里可以被访问。

函数中定义的变量和其他实例仅可以在函数内部访问,不能从外部访问。

如果函数中定义的变量与外部作用域中已有实例同名,则函数内的局部变量定义将覆盖外部定义。

let outerVar = 'I am outer '; // 全局变量,任何地方都能访问

function func() {
    let outerVar = 'I am inside'; // 局部变量,只在 func 函数内有效
    console.info(outerVar); // 输出: I am inside,这里访问的是自己函数内部的 outerVar
}

func();
console.info(outerVar); // 输出: I am outer
// 函数结束后,它内部的 outerVar 就消失了,这里访问的是全局的 outerVar

核心规则:函数内部可以访问外部的变量,但函数内部定义的变量对外部是“隐身”的。

1.6 函数调用

调用函数以执行其函数体,实参值会赋值给函数的形参。

通俗地说,声明函数相当于制造了一把锤子,调用函数才是真正用它去钉钉子。调用时,需要用实际的参数值(实参)去填充定义时的参数位置(形参)。

如果函数定义如下:

function join(x: string, y: string): string {
  let z: string = `${x} ${y}`;
  return z;
}

则此函数的调用需要包含两个string类型的参数,调用过程如下:

let result = join('hello', 'world'); // 1. 传入两个字符串实参
// 2. 在函数内部:x = 'hello', y = 'world'
// 3. 执行函数体:z = 'hello world'
// 4. 返回结果:'hello world'
// 5. 返回值赋值给变量:result = 'hello world'
console.info(x); // 输出: hello world

1.7 函数类型

在ArkTS中,函数本身也可以作为一种“类型”来使用。这主要用于定义“回调函数”——您告诉系统:“事情做完后,请调用我提供的这个函数”。

函数类型通常用于定义回调函数:

type trigFunc = (x: number) => number // 这是一个函数类型

function do_action(f: trigFunc) { // 参数f必须是符合trigFunc类型的函数
  f(3.141592653589); // 调用函数
}

do_action(Math.sin); // 将函数作为参数传入

1.8 箭头函数(Lambda函数)

箭头函数是一种更简洁的函数写法。

函数可以定义为箭头函数,例如:

// 传统函数表达式
let sum1 = function(x: number, y: number): number {
  return x + y;
};

// 等价的箭头函数
let sum2 = (x: number, y: number): number => {
  return x + y;
};

箭头函数的返回类型可以省略,此时返回类型从函数体推断。

表达式可以指定为箭头函数,使表达更简短,因此以下两种表达方式是等价的:

let sum1 = (x: number, y: number) => { return x + y; }
let sum2 = (x: number, y: number) => x + y

1.9 闭包

闭包是由函数及声明该函数的环境组合而成的。该环境包含了这个闭包创建时作用域内的任何局部变量。

在下例中,f函数返回了一个闭包,它捕获了count变量,每次调用z,count的值会被保留并递增。

function f(): () => number { // f是返回一个 “无参数并返回number的函数”
  let count = 0; // count 是 f 函数的局部变量
  let g = (): number => { count++; return count; }; // 内部函数 g 访问了外部变量 count
  return g; // 返回函数 g 本身
}

let z = f(); // z 现在是函数 g,并且它“记住”了 count=0 这个环境
z(); // 执行 g:访问“记住的” count (0),加1后返回 1,count 变为 1。所以返回:1
z(); // 再次执行:访问“记住的” count (现在是1),加1后返回 2。所以返回:2
// count 变量依然存在,且只能继续通过 z 这个函数来访问和修改

1.10 函数重载

可以通过编写重载,指定函数的不同调用方式。具体方法是,为同一个函数写入多个同名但签名不同的函数头,函数实现紧随其后。

通俗地说,函数重载允许一个函数名对应多个不同的“调用签名”,以适应不同类型的参数。ArkTS会检查调用方式,并匹配到正确的函数头。

function foo(x: number): void;            /* 第一个函数定义 */
function foo(x: string): void;            /* 第二个函数定义 */
function foo(x: number | string): void {  /* 函数实现 */
}

foo(123);     //  OK,使用第一个定义
foo('aa'); // OK,使用第二个定义

不允许重载函数有相同的参数列表,否则将导致编译错误。

二、类

2.1 类的定义与实例

想象一下你要建造房子,类就是房子的设计图纸,而实例就是按照图纸建造出来的实际房子。

类声明引入一个新类型,并定义其字段、方法和构造函数。

在以下示例中,定义了Person类,该类具有字段name和surname、构造函数和方法fullName:

// 定义一个 Person 类
class Person {
  // 定义类的字段(属性)
  name: string = '';
  surname: string = '';
  // 构造函数 - 创建对象时自动调用的特殊方法
  constructor (n: string, sn: string) {
    this.name = n; // this 指向当前创建的实例
    this.surname = sn;
  }
   // 定义类的方法
  fullName(): string {
    return this.name + ' ' + this.surname;
  }
}

定义类后,可以使用关键字new创建实例:

// 创建 Person 类的实例
let person1 = new Person('张', '三');
console.info(person1.fullName()); // 输出:张 三

let person2 = new Person('李', '四');
console.info(person2.fullName()); // 输出:李 四

或者,可以使用对象字面量创建实例:

class Point {
  x: number = 0;
  y: number = 0;
}

// 使用对象字面量创建实例
let point1: Point = { x: 10, y: 20 };
let point2: Point = { x: 30, y: 40 };

console.info(point1.x); // 输出:10
console.info(point2.y); // 输出:40

2.2 字段

字段是直接在类中声明的某种类型的变量。就像房子的房间一样,每个实例都有自己的房间来存放东西。

类可以具有实例字段或者静态字段。

2.2.1 实例字段

实例字段存在于类的每个实例上。每个实例都有自己的实例字段集合。

要访问实例字段,需要使用类的实例。

class Student {
  // 实例字段
  name: string = '';
  age: number = 0;
  studentId: string = '';
  
  constructor(n: string, a: number, id: string) {
    this.name = n;
    this.age = a;
    this.studentId = id;
  }
  
  getInfo(): string {
    return `学生:${this.name},年龄:${this.age},学号:${this.studentId}`;
  }
}

// 创建两个学生实例
let student1 = new Student('小明', 18, '2023001');
let student2 = new Student('小红', 19, '2023002');

console.info(student1.getInfo()); // 学生:小明,年龄:18,学号:2023001
console.info(student2.getInfo()); // 学生:小红,年龄:19,学号:2023002

// 每个实例的字段是独立的
student1.age = 20;  // 只修改 student1 的年龄
console.info(student1.age); // 20
console.info(student2.age); // 19 (不变)
2.2.2 静态字段

使用关键字static将字段声明为静态。静态字段属于类本身,类的所有实例共享一个静态字段。

要访问静态字段,需要使用类名:

class School {
  // 静态字段 - 属于类本身
  static schoolName: string = '第一中学';
  static studentCount: number = 0;  // 统计学生总数
  
  name: string = '';
  
  constructor(n: string) {
    this.name = n;
    School.studentCount++;  // 每次创建学生,总数加1
  }
  
  // 静态方法
  static getSchoolInfo(): string {
    return `学校名称:${School.schoolName},学生总数:${School.studentCount}`;
  }
}

// 通过类名访问静态字段和方法
console.info(School.schoolName);  // 第一中学
console.info(School.getSchoolInfo());  // 学校名称:第一中学,学生总数:0

// 创建学生
let stu1 = new School('张三');
let stu2 = new School('李四');
let stu3 = new School('王五');

console.info(School.getSchoolInfo());  // 学校名称:第一中学,学生总数:3
2.2.3 字段初始化

为了减少运行时错误并提升执行性能,ArkTS要求所有字段在声明时或构造函数中显式初始化,与标准TS的strictPropertyInitialization模式相同。就像你租房子,房东必须告诉你房子里都有什么一样。

以下代码在ArkTS中不合法。

class BadExample {
  name: string;  // 编译错误:字段没有初始化
  
  getName(): string {
    return this.name;  // 这里可能返回 undefined
  }
}

在ArkTS中,开发者应该这样写代码。

示例1:声明时初始化

// 声明时初始化
class GoodExample1 {
  name: string = '';  // 初始化为空字符串
  age: number = 0;    // 初始化为0
  
  getInfo(): string {
    return `姓名:${this.name},年龄:${this.age}`;
  }
}

示例2:在构造函数中初始化

// 在构造函数中初始化
class GoodExample2 {
  name: string;
  age: number;
  
  constructor(n: string, a: number) {
    this.name = n;  // 在构造函数中初始化
    this.age = a;
  }
}

示例3:接下来的代码示例展示了当name的值可能为undefined时,如何正确编写代码。

class Person {
  name?: string; // 可能为`undefined`

  setName(n: string): void {
    this.name = n;
  }

  // 编译时错误:name可以是"undefined",所以这个API的返回值类型不能仅定义为string类型
  getNameWrong(): string {
    return this.name;
  }

  getName(): string | undefined { // 返回类型匹配name的类型
    return this.name;
  }
}

let jack = new Person();
// 假设代码中没有对name赋值,即没有调用"jack.setName('Jack')"

// 编译时错误:编译器认为下一行代码有可能会访问undefined的属性,报错
jack.getName().length;  // 编译失败

jack.getName()?.length; // 编译成功,没有运行时错误
2.2.4 getter和setter方法

setter和getter可用于提供对类属性的受控访问。

在以下示例中,setter用于禁止将_age属性设置为无效值:

class Person {
  name: string = '';
  private _age: number = 0;
  get age(): number { return this._age; }
  set age(x: number) {
    if (x < 0) {
      throw Error('Invalid age argument');
    }
    this._age = x;
  }
}

let p = new Person();
p.age; // 输出0
p.age = -42; // 设置无效age值会抛出错误

在类中可以定义getter或者setter。

2.3 方法

方法属于类。类可以定义实例方法或者静态方法。静态方法属于类本身,只能访问静态字段。而实例方法既可以访问静态字段,也可以访问实例字段,包括类的私有字段。

简单来说,方法是类的功能,定义了对象能做什么。就像手机有打电话、拍照等功能一样。

2.3.1 实例方法

实例方法属于每个对象实例,可以访问实例的字段。

以下示例说明了实例方法的工作原理。

calculateArea方法计算矩形面积:

class RectangleSize {
  private height: number = 0;
  private width: number = 0;
  constructor(height: number, width: number) {
    this.height = height;
    this.width = width;
  }
  calculateArea(): number {
    return this.height * this.width;
  }
}

必须通过类的实例调用实例方法:

let square = new RectangleSize(10, 10);
square.calculateArea(); // 输出:100
2.3.2 静态方法

使用关键字 static 声明静态方法。静态方法属于类,只能访问静态字段。

静态方法定义了类作为一个整体的公共行为。静态方法不依赖任何实例。

必须通过类名调用静态方法:

class MathUtils {
  // 静态方法
  static add(a: number, b: number): number {
    return a + b;
  }
  
  static max(a: number, b: number): number {
    return a > b ? a : b;
  }
  
  static formatNumber(num: number): string {
    return `数值:${num}`;
  }
}

// 直接通过类名调用静态方法
console.info(MathUtils.add(5, 3));  // 8
console.info(MathUtils.max(10, 20));  // 20
console.info(MathUtils.formatNumber(100));  // 数值:100
2.3.3 继承

继承就像孩子继承父母的特征,子类会继承父类的字段和方法。一个类可以继承另一个类(称为基类),并使用以下语法实现多个接口:

class [extends BaseClassName] [implements listOfInterfaces] {
  // ...
}

继承类继承基类的字段和方法,但不继承构造函数。继承类可以新增定义字段和方法,也可以覆盖其基类定义的方法。

基类也称为“父类”或“超类”。继承类也称为“派生类”或“子类”。

示例:

// 父类(基类)
class Animal {
  name: string = '';
  age: number = 0;
  
  constructor(n: string, a: number) {
    this.name = n;
    this.age = a;
  }
  
  eat(): void {
    console.log(`${this.name} 正在吃东西`);
  }
  
  sleep(): void {
    console.log(`${this.name} 正在睡觉`);
  }
}

// 子类(派生类)
class Dog extends Animal {
  breed: string = '';
  
  constructor(n: string, a: number, b: string) {
    super(n, a);  // 调用父类的构造函数
    this.breed = b;
  }
  
  bark(): void {
    console.log(`${this.name} 在汪汪叫`);
  }
  
  // 重写父类方法
  eat(): void {
    console.log(`${this.name} 正在吃狗粮`);
  }
}

// 另一个子类
class Cat extends Animal {
  color: string = '';
  
  constructor(n: string, a: number, c: string) {
    super(n, a);
    this.color = c;
  }
  
  meow(): void {
    console.log(`${this.name} 在喵喵叫`);
  }
  
  // 重写父类方法
  eat(): void {
    console.log(`${this.name} 正在吃猫粮`);
  }
}

// 使用继承的类
let myDog = new Dog('旺财', 3, '金毛');
let myCat = new Cat('咪咪', 2, '白色');

myDog.eat();    // 旺财 正在吃狗粮
myDog.bark();   // 旺财 在汪汪叫
myDog.sleep();  // 旺财 正在睡觉(继承自父类)

myCat.eat();    // 咪咪 正在吃猫粮
myCat.meow();   // 咪咪 在喵喵叫
myCat.sleep();  // 咪咪 正在睡觉(继承自父类)
2.3.4 实现接口

接口定义了类必须实现的方法,就像合同规定了必须完成的工作。

包含implements子句的类必须实现列出的接口中定义的所有方法,但使用默认实现定义的方法除外。

// 定义接口
interface Movable {
  move(): void;
}

interface Soundable {
  makeSound(): void;
}

// 类实现接口
class Car implements Movable, Soundable {
  brand: string = '';
  
  constructor(b: string) {
    this.brand = b;
  }
  
  // 实现 Movable 接口的方法
  move(): void {
    console.log(`${this.brand} 汽车正在行驶`);
  }
  
  // 实现 Soundable 接口的方法
  makeSound(): void {
    console.log(`${this.brand} 汽车:滴滴!`);
  }
}

let myCar = new Car('鸿蒙智行');
myCar.move();       // 鸿蒙智行 汽车正在行驶
myCar.makeSound();  // 鸿蒙智行 汽车:滴滴!
2.3.5 使用 super 访问父类

关键字super可用于访问父类的方法和构造函数。在实现子类功能时,可以通过该关键字从父类中获取所需接口:

class RectangleSize {
  protected height: number = 0;
  protected width: number = 0;

  constructor (h: number, w: number) {
    this.height = h;
    this.width = w;
  }

  draw() {
    /* 绘制边界 */
  }
}
class FilledRectangle extends RectangleSize {
  color = ''
  constructor (h: number, w: number, c: string) {
    super(h, w); // 父类构造函数的调用
    this.color = c;
  }

  draw() {
    super.draw(); // 父类方法的调用
    /* 填充矩形 */
  }
}
2.3.6 方法重写

子类可以重写其父类中定义的方法的实现。重写的方法必须具有与原始方法相同的参数类型和相同或派生的返回类型。

class RectangleSize {
  // ...
  area(): number {
    // 实现
    return 0;
  }
}
class Square extends RectangleSize {
  private side: number = 0;
  area(): number {
    return this.side * this.side;
  }
}
2.3.7 方法重载签名

通过重载签名,指定方法的不同调用。具体方法为,为同一个方法写入多个同名但签名不同的方法头,方法实现紧随其后。

class C {
  foo(x: number): void;            // 重载签名1:接收数字
  foo(x: string): void;            // 重载签名2:接收字符串
  foo(x: number | string): void {  // 重载签名3
  }
}
let c = new C();
c.foo(123);     // OK,使用第一个签名
c.foo('aa'); // OK,使用第二个签名

如果两个重载签名的名称和参数列表均相同,则为错误。

2.4 构造函数

构造函数是创建对象时自动调用的特殊方法,用于初始化对象。

类声明可以包含用于初始化对象状态的构造函数。

构造函数定义如下:

constructor ([parameters]) {
  // ...
}

如果未定义构造函数,则会自动创建具有空参数列表的默认构造函数,例如:

class Point {
  x: number = 0;
  y: number = 0;
}
let p = new Point();

在这种情况下,默认构造函数使用字段类型的默认值初始化实例中的字段。

2.4.1 派生类的构造函数

构造函数函数体的第一条语句可以使用关键字super来显式调用直接父类的构造函数。

class RectangleSize {
  constructor(width: number, height: number) {
    // ...
  }
}
class Square extends RectangleSize {
  constructor(side: number) {
    super(side, side);
  }
}
2.4.2 构造函数重载签名

可以通过编写重载签名,指定构造函数的不同调用方式。具体方法是,为同一个构造函数写入多个同名但签名不同的构造函数头,构造函数实现紧随其后。

class C {
  constructor(x: number)             /* 第一个签名 */
  constructor(x: string)             /* 第二个签名 */
  constructor(x: number | string) {  /* 实现签名 */
  }
}
let c1 = new C(123);      // OK,使用第一个签名
let c2 = new C('abc');    // OK,使用第二个签名

如果两个重载签名的名称和参数列表均相同,则为错误。

2.5 可见性修饰符

类的方法和属性都可以使用可见性修饰符。可见性修饰符就像门锁,控制着谁能访问类的成员。

可见性修饰符包括:private、protected和public。默认可见性为public。

2.5.1 Public(公有)

公有成员就像公共场所,谁都可以访问。

public修饰的类成员(字段、方法、构造函数)在程序的任何可访问该类的地方都是可见的。

class PublicExample {
  public name: string = '';  // public 可以省略,因为默认就是 public
  age: number = 0;           // 默认是 public
  
  public sayHello(): void {
    console.log(`你好,我是${this.name}`);
  }
}

let publicObj = new PublicExample();
publicObj.name = '张三';     // 可以访问
publicObj.age = 20;         // 可以访问
publicObj.sayHello();       // 可以访问
2.5.2 Private(私有)

私有成员就像私人日记,只有自己能看到。

private修饰的成员不能在声明该成员的类之外访问,例如:

class C {
  public x: string = '';
  private y: string = '';
  set_y (new_y: string) {
    this.y = new_y; // OK,因为y在类本身中可以访问
  }
}
let c = new C();
c.x = 'a'; // OK,该字段是公有的
c.y = 'b'; // 编译时错误:'y'不可见
2.5.3 Protected(受保护)

受保护成员就像家族秘密,只有家人和后代能知道。

protected修饰符的作用与private修饰符非常相似,不同点是protected修饰的成员允许在派生类中访问,例如:

class Base {
  protected x: string = '';
  private y: string = '';
}
class Derived extends Base {
  foo() {
    this.x = 'a'; // OK,访问受保护成员
    this.y = 'b'; // 编译时错误,'y'不可见,因为它是私有的
  }
}

2.6 对象字面量

对象字面量是一个表达式,可用于创建类实例并提供一些初始值。它在某些情况下更方便,可以用来代替new表达式。对象字面量是一种快速创建对象的方式。

对象字面量的表示方式是:封闭在花括号对({})中的'属性名:值'的列表。

class C {
  n: number = 0;
  s: string = '';
}

let c: C = {n: 42, s: 'foo'};

ArkTS是静态类型语言,如上述示例所示,对象字面量只能在可以推导出该字面量类型的上下文中使用。其他正确的例子如下所示:

class C {
  n: number = 0;
  s: string = '';
}

function foo(c: C) {}

let c: C

c = {n: 42, s: 'foo'};  // 使用变量的类型
foo({n: 42, s: 'foo'}); // 使用参数的类型

function bar(): C {
  return {n: 42, s: 'foo'}; // 使用返回类型
}

也可以在数组元素类型或类字段类型中使用:

class C {
  n: number = 0;
  s: string = '';
}
let cc: C[] = [{n: 1, s: 'a'}, {n: 2, s: 'b'}];
2.6.1 Record类型的对象字面量

泛型Record<K, V>用于将类型(键类型)的属性映射到另一个类型(值类型)。常用对象字面量来初始化该类型的值:

let map: Record<string, number> = {
  'John': 25,
  'Mary': 21
};

map['John']; // 25

类型K可以是字符串类型或数值类型(不包括BigInt),而V可以是任何类型。

interface PersonInfo {
  age: number;
  salary: number;
}
let map: Record<string, PersonInfo> = {
  'John': { age: 25, salary: 10},
  'Mary': { age: 21, salary: 20}
}

2.7 抽象类

抽象类就像是未完成的蓝图,它定义了基本结构,但不能直接使用,需要子类来完成。

带有abstract修饰符的类称为抽象类。抽象类可用于表示一组更具体的概念所共有的概念。

尝试创建抽象类的实例会导致编译错误:

abstract class X {
  field: number;
  constructor(p: number) {
    this.field = p;
  }
}

let x = new X(666)  //编译时错误:不能创建抽象类的具体实例

抽象类的子类可以是抽象类也可以是非抽象类。抽象父类的非抽象子类可以实例化。因此,执行抽象类的构造函数和该类非静态字段的字段初始化器:

abstract class Base {
  field: number;
  constructor(p: number) {
    this.field = p;
  }
}

class Derived extends Base {
  constructor(p: number) {
    super(p);
  }
}

let x = new Derived(666);
2.7.1 抽象方法

带有abstract修饰符的方法称为抽象方法,抽象方法可以被声明但不能被实现。

只有抽象类内才能有抽象方法,如果非抽象类具有抽象方法,则会发生编译时错误:

class Y {
  abstract method(p: string)  //编译时错误:抽象方法只能在抽象类内。
}

如果在学习过程中遇到任何问题,欢迎在评论区留言交流。

后续我将持续更新更多HarmonyOS开发教程,涵盖从基础到进阶的各个知识点,敬请关注!

          Logo

          讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

          更多推荐