简述: 这是泛型型变最后一篇文章了,也是泛型介绍的最后一篇文章。顺便再扯点别的,上周去北京参加了JetBrains 2018开发者日,主要是参加Kotlin专场。个人感觉收获还是挺多的,bennyHuo和彦伟老师精彩演讲确实传递很多干货啊,当然还有Hali布道师大佬带来了的Kotlin1.3版本的新特性以及Google中国技术推广负责人钟辉老师带来的Coroutines在Android开发中的应用。所以准备整理如下几篇文章为后续发布:

  • 1、Kotlin中1.3版本新特性都有哪些?
  • 2、Kotlin中的Coroutine(协程)在Android上应用(协程学前班篇)
  • 3、Ktor异步框架初体验(Ktor学前班篇)
  • 4、Kotlin中data class的使用(benny大佬在大会上讲的很清楚了,也很全面。主要讲下个人之前踩过的坑,特别是用于后端开发坑更多)

那么今天这篇文章主要是为了给上篇型变文章两个尾巴以及泛型型变是如何被应用到实际开发中的去。并且我会用上篇博客如何去选择相应型变的方法一步步确定最终我们该使用协变、逆变、还是不变,我会用一个实际例子来说明。这篇文章比较简单主要就以下四点:

  • 1、Kotlin声明点变型与Java中的使用点变型进行对比
  • 2、如何使用Kotlin中的使用点变型
  • 3、Kotlin泛型中的星投影
  • 4、使用泛型型变实现可用于实际开发中的Boolean扩展

一、Kotlin声明点变型与Java中的使用点变型进行对比

1、声明点变型和使用点变型定义区别

首先,解释下什么是声明点变型和使用点变型,声明点变型顾名思义就是在定义声明泛型类的时候指明型变类型(协变、逆变、不变),在Kotlin上表现形式就是在声明泛型类时候在泛型形参前面加in或out修饰。使用点变型就是在每次使用该泛型类的时候都要去明确指出型变关系,如果你对Java中型变熟悉的话,Java就是使用了使用点变型.

2、两者优点对比

声明点变型:

  • 有个明显优点就是只需要在泛型类声明时定义一次型变对应关系就可以了,那么之后不管在任何地方使用它都不用显示指定型变对应关系,而使用点变型就是每处使用的地方都得重复定义一遍特别麻烦(又找到一处Kotlin优于Java的地方)。

使用点变型:

  • 实际上使用点变型也是有使用场景的,可以使用的更加灵活;所以Kotlin并没有完全摒弃这个语法点,下面会专门介绍它的使用场景。

3、使用对比

刚刚说使用点变型特别麻烦,一起来看看到底有多麻烦。这里就是以Java为代表,我们都知道Java中要使用型变,是利用?通配符加(super/extends)来达到目的,例如: Function<? super T, ? extends E>, 其中的? extends E就是对应了协变,而? super T对应的是逆变。这里以Stream API中的flatMap函数源码为例

@FunctionalInterface
public interface Function<T, R> {//声明处就不用指定型变关系...
}//可以看到使用点变型非常麻烦,定义一个mapper的Function泛型类参数时,还需要指明后面一大串Function<? super T, ? extends Stream<? extends R>><R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);

声明点变型到底有多方便,这里就以Kotlin为例,Kotlin使用in, out来实现型变对应规则。这里以Sequences API中的flapMap函数源码为例


public interface Sequence<out T> {//Sequence定义处声明了out协变/*** Returns an [Iterator] that returns the values from the sequence.** Throws an exception if the sequence is constrained to be iterated once and `iterator` is invoked the second time.*/public operator fun iterator(): Iterator<T>
}public fun <T, R> Sequence<T>.flatMap(transform: (T) -> Sequence<R>): Sequence<R> {//可以看到由于Sequence声明了协变,所以flatMap函数Sequence中的泛型实参R就不用再次指明型变类型了return FlatteningSequence(this, transform, { it.iterator() })
}

通过以上源码对比,明显看出Kotlin中的声明点变型要比Java中的使用点变型要简单得多吧。但是呢使用点变型并不是一无是处,它在Kotlin中还是有一定的使用场景的。下面即将揭晓

二、如何使用Kotlin中的使用点变型

实际上使用点变型在Kotlin中还是有一定的使用场景,想象一下这样一个实际场景,尽管某个泛型类是不变的,也就是具有可读可写的操作,可是有时候在某个函数中,我们一般仅仅只用到只读或只写操作,这时候利用使用点变型它能使一个不变型的缩小型变范围蜕化成协变或逆变的。是不是突然懵逼了,用源码来说话,你就明白了,一起来看个源码中的例子。

Kotlin中的MutableCollection<E>是不变的,一起来看了下它的定义

public interface MutableCollection<E> : Collection<E>, MutableIterable<E> {//没有in和out修饰,说明是不变override fun iterator(): MutableIterator<E>public fun add(element: E): Booleanpublic fun remove(element: E): Booleanpublic fun addAll(elements: Collection<E>): Booleanpublic fun removeAll(elements: Collection<E>): Booleanpublic fun retainAll(elements: Collection<E>): Booleanpublic fun clear(): Unit
}

然后我们接着看filter和filterTo函数的源码定义

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {return filterTo(ArrayList<T>(), predicate)
}//注意: 这里<T, C : MutableCollection<in T>>, MutableCollection<in T>声明成逆变的了,是不是很奇怪啊,之前明明有说它是不变的啊,怎么这里就声明逆变了
public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {for (element in this) if (predicate(element)) destination.add(element)return destination
}

通过上面的函数是不是发现和MutableCollection不变相违背啊,实际上不是的。这里就是一种典型的使用点变型的使用,我们可以再仔细分析下这个函数,destination在filterTo函数的内部只做了写操作,遍历Iterable中的元素,并把他们add操作到destination集合中,可以验证我们上述的结论了,虽然MutableCollection是不变的,但是在函数内部只涉及到写操作,完全就可以使用 使用点变型将它指定成一个逆变的型变类型,由不变退化成逆变明显不会影响泛型安全所以这里处理是完全合法的。可以再去看其他集合操作API,很多地方都使用了这种方式。

上述关于不变退化到逆变的,这里再讲个不变退化到协变的例子。

//可以看到source集合泛型类型声明成了out协变了???
fun <T> copyList(source: MutableList<out T>, destination: MutableList<T>): MutableList<T>{for (element in source) destination.add(element)
}

MutableList<E>就是前面常说的不变的类型,同样具有可读可写操作,但是这里的source的集合泛型类型声明成了out协变,会不会又蒙了。应该不会啊,有了之前逆变的例子,应该大家都猜到为什么了。很简单就是因为在copyList函数中,source集合没有涉及写操作只有读操作,所以可以使用 使用点变型将MutableList的不变型退化成协变型,而且很显然不会引入泛型安全的问题。

所以经过上述例子和以前例子关于如何使用逆变、协变、不变。还是我之前说那句话,不要去死记规则,关键在于使用场景中读写操作是否引入泛型类型安全的问题。如果明确读写操作的场景了完全可以按照上述例子那样灵活运用泛型的型变的,可以程序写得更加完美。

三、Kotlin泛型中的星投影

1、星投影的定义

星投影是一种特殊的星号投影,它一般用来表示不知道关于泛型实参的任何信息,换句话说就是它表示一种特定的类型,但是只是这个类型不知道或者不能被确定而已。

2、MutableList<*>MutableList<Any?>区别

首先我们需要注意和明确的一点就是MutableList<*>MutableList<Any?>是不一样的,MutableList<*>表示包含某种特定类型的集合;而MutableList<Any?>则是包含任意类型的集合。特定类型集合只不过不太确定是哪种类型,任意类型表示包含了多种类型,区别在于特定集合类型一旦确定类型,该集合只能包含一种类型;而任意类型就可以包含多种类型了。

3、MutableList<*>实际上一个out协变投影

MutableList<*>实际上是投影成MutableList<out Any?>类型

首先,我们来分析下为什么会这样投影,我们知道MutableList<*>只包含某种特定类型的集合,可能是String、Int或者其他类型中的一种,可想而知对于该集合操作需要禁止写操作,不能往该集合中写入数据,因为无法确定该集合的特定类型,写操作很可能引入一个不匹配类型到集合中,这是一件很危险的事。但是反过来想下,如果该集合存在只读操作,读出数据元素类型虽然不知道,但是始终是安全的。只存在读操作那么说明是协变,协变就会存在保留子类型化关系,也就是读出数据元素类型是不确定类型子类型,那么可想而知它只替换Any?类型的超类型,因为Any?是所有类型的超类型,那么保留型化关系,所以MutableList<*>实际上就是MutableList<out Any?>的子类型了。

四、使用泛型型变实现可用于实际开发中的Boolean扩展

关于Boolean扩展的实现,主要来源于看了BennyHuo大佬写的一些代码中发现的,原来可以这么方便的写if-else,于是乎就去看了下它的实现
可能很多人都知道了它的实现,为什么要讲这个因为这是Kotlin泛型协变实际应用一个非常不错的例子。

1、为什么开发一个Boolean扩展

给出一个例子场景,判断一堆数集合中是否全是奇数,如果全是返回输出"奇数集合",如果不是请输出"不是奇数集合"

首先问下大家是否写过一下类似下面代码

//java版写法public void isOddList(){int count = 0;for(int i = 0; i < numberList.size(); i++){if(numberList[i] % 2 == 1){count++;}}if(count == numberList.size()){System.out.println("奇数集合");return;}System.out.println("不是奇数集合");
}
//kotlin版写法fun isOddList() = println(if(numberList.filter{ it % 2 == 1}.count().equals(numberList.size)){"奇数集合"} else {"不是奇数集合"})
//Boolean扩展版本写法
fun isOddList() = println(numberList.filter{ it % 2 == 1 }.count().equals(numberList.size).yes{"奇数集合"}.otherwise{"不是奇数集合"})//有没有发现Boolean扩展这种链式调用更加丝滑

对比发现,虽然Kotlin中的if-else表达式自带返回值的,但是if-else的结构会打断链式调用,但是如果使用Boolean扩展,完全可以使你的链式调用更加丝滑顺畅一路调用到底。

2、Boolean扩展使用场景

Boolean扩展的使用场景个人认为有两个:

  • 配合函数式API一起使用,遇到if-else判断的时候建议使用Boolean扩展,因为它不会像if-else结构一样会打断链式调用的结构。
  • 另一场景就是if的判断条件组合很多,如果在外层再包裹一个if代码显得更加臃肿了,此时使用Boolean会使代码更简洁。

3、Boolean代码实现

通过观察上述Boolean扩展的使用,我们首先需要明确几点:

  • 第一点:我们知道yes、otherwise实际上就是两个函数,为什么能链式链接起来说明中间肯定有一个类似桥梁作用的中间类型作为函数的返回值类型。
  • 第二点:yes、otherwise函数的作用域是带返回值的,例如上述例子它能直接返回字符串类型的数据。
  • 第三点: yes、oterwise函数的都是一个lamba表达式,并且这个lambda表达式将最后表达式中的值返回
  • 第四点: yes函数是在Boolean类型调用,所以需要基于Boolean类型的实现扩展函数

那么根据以上得出几点特征基本可以把这个扩展的简单版本写出来了(暂时不支持带返回值的)

//作为中间类型,实现链式链接
sealed class BooleanExt
object Otherwise : BooleanExt()
object TransferData : BooleanExt()fun Boolean.yes(block: () -> Unit): BooleanExt = when {this -> {block.invoke()TransferData//由于返回值是BooleanExt,所以此处也需要返回一个BooleanExt对象或其子类对象,故暂且定义TransferData object继承BooleanExt}else -> {//此处为else,那么需要链接起来,所以需要返回一个BooleanExt对象或其子类对象,故定义Otherwise object继承BooleanExtOtherwise}
}//为了链接起otherwise方法操作所以需要写一个BooleanExt类的扩展
fun BooleanExt.otherwise(block: () -> Unit) = when (this) {is Otherwise -> block.invoke()//判断此时子类,如果是Otherwise子类执行blockelse -> Unit//不是,则直接返回一个Unit即可
}fun main(args: Array<String>) {val numberList: List<Int> = listOf(1, 2, 3)//使用定义好的扩展(numberList.size == 3).yes {println("true")}.otherwise {println("false")}
}

上述的简单版基本上把扩展的架子搭出来但是呢,唯一没有实现返回值的功能,加上返回值的功能,这个最终版本的Boolean扩展就实现了。

现在来改造一下原来的版本,要实现返回值那么block函数不能再返回Unit类型,应该要返回一个泛型类型,还有就是TransferData不能使用object对象表达式类型,因为需要利用构造器传入泛型类型的参数,所以TransferData用普通类替代就好了。

关于是定义成协变、逆变还是不变型,我们可以借鉴上篇文章使用到流程选择图和对比表格

将从基本结构形式、有无子类型化关系(保留、反转)、有无型变点(协变点out、逆变点in)、角色(生产者输出、消费者输入)、类型形参存在的位置(协变就是修饰只读属性和函数返回值类型;逆变就是修饰可变属性和函数形参类型)、表现特征(只读、可写、可读可写)等方面进行对比

协变 逆变 不变
基本结构 Producer<out E> Consumer<in T> MutableList<T>
子类型化关系 保留子类型化关系 反转子类型化关系 无子类型化关系
有无型变点 协变点out 逆变点in 无型变点
类型形参存在的位置 修饰只读属性类型和函数返回值类型 修饰可变属性类型和函数形参类型 都可以,没有约束
角色 生产者输出为泛型形参类型 消费者输入为泛型形参类型 既是生产者也是消费者
表现特征 内部操作只读 内部操作只写 内部操作可读可写

  • 第一步:首先根据类型形参存在位置以及表现特征确定
sealed class BooleanExt<T>object Otherwise : BooleanExt<Any?>()class TransferData<T>(val data: T) : BooleanExt<T>()//val修饰datainline fun <T> Boolean.yes(block: () -> T): BooleanExt<T> = when {//T处于函数返回值位置this -> {TransferData(block.invoke())}else -> Otherwise//注意: 此处是编译不通过的
}inline fun <T> BooleanExt<T>.otherwise(block: () -> T): T = when (this) {//T处于函数返回值位置is Otherwise ->block()is TransferData ->this.data
}

通过以上代码我们可以基本确定是协变或者不变,

  • 第二步:判断是否存在子类型化关系

由于yes函数else分支返回的是Otherwise编译不通过,很明显此处不是不变的,因为上述代码就是按照不变方式来写的。所以基本确定就是协变。

然后接着改,首先将sealed class BooleanExt<T>改为sealed class BooleanExt<out T>协变声明,然后发现Otherwise还是报错,为什么报错啊,报错原因是因为yes函数要求返回一个BooleanExt<T>类型,而此时返回Otherwise是个BooleanExt<Any?>(),反证法,假如上述是合理,那么也就是BooleanExt<Any?>要替代BooleanExt<T>出现的地方,BooleanExt<Any?>BooleanExt<T>子类型,由于BooleanExt<T>协变的,保留子类型型化关系也就是Any?T子类型,明显不对吧,我们都知道Any?是所有类型的超类型。所以原假设明显不成立,所以编译错误很正常,那么逆向思考下,我是不是只要把Any?位置用所有的类型的子类型Nothing来替换不就符合了吗,那么我们自然而然就想到Nothing,在Kotlin中Nothing是所有类型的子类型。所以最终版本Boolean扩展代码如下

sealed class BooleanExt<out T>//定义成协变object Otherwise : BooleanExt<Nothing>()//Nothing是所有类型的子类型,协变的类继承关系和泛型参数类型继承关系一致class TransferData<T>(val data: T) : BooleanExt<T>()//data只涉及到了只读的操作//声明成inline函数
inline fun <T> Boolean.yes(block: () -> T): BooleanExt<T> = when {this -> {TransferData(block.invoke())}else -> Otherwise
}inline fun <T> BooleanExt<T>.otherwise(block: () -> T): T = when (this) {is Otherwise ->block()is TransferData ->this.data
}

五、结语

到这里Kotlin中有关泛型的所有文章就结束,当然泛型很重要深入于实际开发各个地方,特别是开发一些框架东西比较多,可以看到上述Boolean实现就是按照上篇文章教你如何攻克Kotlin中泛型型变的难点(下篇)规则来决定使用哪种型变类型以及稍加分析下就出来了。总的来说有了那张图做指导还是很方便的。其实关于泛型型变,还是得需要多理解,不能死记规则,只有这样才能更加灵活运用。最后非常感谢bennyHuo大佬提供的Boolean扩展实现。

Kotlin系列文章,欢迎查看:

欢迎关注Kotlin开发者联盟,这里有最新Kotlin技术文章,每周会不定期翻译一篇Kotlin国外技术文章。如果你也喜欢Kotlin,欢迎加入我们~~~

Kotlin系列文章,欢迎查看:

Kotlin邂逅设计模式系列:

  • 当Kotlin完美邂逅设计模式之单例模式(一)

数据结构与算法系列:

  • 每周一算法之二分查找(Kotlin描述)

翻译系列:

  • [译] Kotlin中关于Companion Object的那些事
  • [译]记一次Kotlin官方文档翻译的PR(内联类)
  • [译]Kotlin中内联类的自动装箱和高性能探索(二)
  • [译]Kotlin中内联类(inline class)完全解析(一)
  • [译]Kotlin的独门秘籍Reified实化类型参数(上篇)
  • [译]Kotlin泛型中何时该用类型形参约束?
  • [译] 一个简单方式教你记住Kotlin的形参和实参
  • [译]Kotlin中是应该定义函数还是定义属性?
  • [译]如何在你的Kotlin代码中移除所有的!!(非空断言)
  • [译]掌握Kotlin中的标准库函数: run、with、let、also和apply
  • [译]有关Kotlin类型别名(typealias)你需要知道的一切
  • [译]Kotlin中是应该使用序列(Sequences)还是集合(Lists)?
  • [译]Kotlin中的龟(List)兔(Sequence)赛跑

原创系列:

  • 教你如何完全解析Kotlin中的类型系统
  • 如何让你的回调更具Kotlin风味
  • Jetbrains开发者日见闻(三)之Kotlin1.3新特性(inline class篇)
  • JetBrains开发者日见闻(二)之Kotlin1.3的新特性(Contract契约与协程篇)
  • JetBrains开发者日见闻(一)之Kotlin/Native 尝鲜篇
  • 教你如何攻克Kotlin中泛型型变的难点(实践篇)
  • 教你如何攻克Kotlin中泛型型变的难点(下篇)
  • 教你如何攻克Kotlin中泛型型变的难点(上篇)
  • Kotlin的独门秘籍Reified实化类型参数(下篇)
  • 有关Kotlin属性代理你需要知道的一切
  • 浅谈Kotlin中的Sequences源码解析
  • 浅谈Kotlin中集合和函数式API完全解析-上篇
  • 浅谈Kotlin语法篇之lambda编译成字节码过程完全解析
  • 浅谈Kotlin语法篇之Lambda表达式完全解析
  • 浅谈Kotlin语法篇之扩展函数
  • 浅谈Kotlin语法篇之顶层函数、中缀调用、解构声明
  • 浅谈Kotlin语法篇之如何让函数更好地调用
  • 浅谈Kotlin语法篇之变量和常量
  • 浅谈Kotlin语法篇之基础语法

Effective Kotlin翻译系列

  • [译]Effective Kotlin系列之考虑使用原始类型的数组优化性能(五)
  • [译]Effective Kotlin系列之使用Sequence来优化集合的操作(四)
  • [译]Effective Kotlin系列之探索高阶函数中inline修饰符(三)
  • [译]Effective Kotlin系列之遇到多个构造器参数要考虑使用构建器(二)
  • [译]Effective Kotlin系列之考虑使用静态工厂方法替代构造器(一)

实战系列:

  • 用Kotlin撸一个图片压缩插件ImageSlimming-导学篇(一)
  • 用Kotlin撸一个图片压缩插件-插件基础篇(二)
  • 用Kotlin撸一个图片压缩插件-实战篇(三)
  • 浅谈Kotlin实战篇之自定义View图片圆角简单应用

教你如何攻克Kotlin中泛型型变的难点(应用篇)相关推荐

  1. 教你如何攻克Kotlin中泛型型变的难点(下篇)

    简述: 前几天我们一起为Kotlin中的泛型型变做了一个很好的铺垫,深入分析下类型和类,子类型和子类之间的关系.什么是子类型化关系以及型变存在的意义.那么今天将会讲点更刺激的东西,也就是Kotlin泛 ...

  2. 跟着小老弟来学习Kotlin中的逆变和协变

    /   今日科技快讯   / 近日,小米创始人.董事长兼CEO雷军在抖音上开启了其直播带货的首秀.从晚上8点开播,到晚上10点,销售额就已经破亿.包括1000台售价49999元的透明电视在内的商品一推 ...

  3. 教你如何完全解析Kotlin中的注解

    简述: 从这篇文章将继续开始探索Kotlin中的一些高级的内容,之前有着重探讨了Kotlin的泛型以及泛型型变等内容.现在我们一起来看下Kotlin中的注解.Kotlin中的注解是100%与Java注 ...

  4. 教你如何完全解析Kotlin中的类型系统

    简述: 已经很久没有更新文章,这大概是2019年第二篇文章了,有很多小伙伴们都在公众号留言说是不是断更了.是不是跑路了.在这里统一回复下我还好,并没有跑路哈,只是在思考接下来文章主要方向在哪? 如何在 ...

  5. [译]带你揭开Kotlin中属性代理和懒加载语法糖衣

    翻译说明: 原标题: How Kotlin's delegated properties and lazy-initialization work 原文地址: https://medium.com/t ...

  6. [译]Kotlin中是应该使用序列(Sequences)还是集合(Lists)?

    翻译说明: 原标题: Sequences - a Pragmatic Approach 原文地址: https://proandroiddev.com/sequences-a-pragmatic-ap ...

  7. 用Kotlin撸一个图片压缩插件-插件基础篇(二)

    简述: 前两天写了篇用Kotlin撸一个图片压缩插件-导学篇,现在迎来了插件基础篇,没错这篇文章就是教你如何一步一步从零开始写一个插件,包括插件项目构建,运行,调试到最后的上线发布整个流程.如果你是插 ...

  8. kotlin之泛型的使用

    泛型 我们最先了解到的泛型应该是来自于Java,在Java SE 1.5的时候,首次提出了泛型的概念,泛型的本质是参数化的类型,也就是说传递操作的数据类型被指定为一个参数,泛型可以被应用于类(泛型类) ...

  9. scala 定义空的list_18.scala的型变

    型变是复杂类型的子类型关系与其组件类型的子类型关系的相关性.Scala支持 泛型类 的类型参数的型变注释,允许它们是协变的,逆变的,或在没有使用注释的情况下是不变的.在类型系统中使用型变允许我们在复杂 ...

最新文章

  1. xgboost源码 要看的
  2. 饿了么口碑活跃用户增长近美团3倍,2020年行业竞争局势将扭转?
  3. 敏捷团队如何进行绩效考核?
  4. Struts2显示double价格格式0.00
  5. I/O(输入/输出)---序列化与反序列化
  6. 马斯克发推:8月特斯拉Autopilot实现完全自动驾驶
  7. 17、Java并发性和多线程-避免死锁
  8. 更改VS2010,VS2008,VS2012等指定默认浏览器操作方式
  9. Ubuntu上安装ns2-2.34
  10. Spring源码阅读-BeanFactory初始化-配置加载
  11. 计算机指数函数符号,常用数学符号大全(注音及注解)
  12. wunderlist_如何从Wunderlist切换到Microsoft做
  13. Windows添加共享文件夹添加一个网络位置图文教程
  14. LeetCode—面试题:移除重复节点(哈希集合)
  15. box-sizing属性的的用法
  16. 【WWW2021】图知识蒸馏
  17. oracle函数笔记
  18. iOS 架构模式 - 简述 MVC, MVP, MVVM
  19. 数据传输性能与安全不能兼顾?Rambus安全方案“动静”两相宜
  20. 腾讯技术团队整理,万字长文轻松彻底入门 Flutter,秒变大前端

热门文章

  1. 达人评测 GTX1650Ti、GTX1660Ti和RTX3050Ti差距大不大
  2. js整形int和byte数组互相转换
  3. C语言 全字母句,【C语言程序】让用户输入一句话,输出这句话中每个单词含有多少个字母...
  4. 2021-2027全球及中国潮牌鞋行业研究及十四五规划分析报告
  5. 数据集标注工具_创新工场提出中文分词和词性标注模型,性能分别刷新五大数据集| ACL 2020...
  6. 在Delphi中如何使用TTask并行程序进行多线程下载
  7. 在“PS设计精讲精练”一课中的学习收获(4)
  8. 人体工学椅真的很舒服
  9. 用STM32读取6轴角度传感器JY61的陀螺仪、加速度、角度数据MPU6050
  10. 四轮 控制算法 麦轮_四轮麦克纳姆轮巡检机器人运动控制方法与流程