Kotlin puzzlers, Vol. 2: новая порция головоломок

в 13:36, , рубрики: java, kotlin, puzzlers, Антон Кекс, Блог компании JUG.ru Group, Разработка под android

Kotlin puzzlers, Vol. 2: новая порция головоломок - 1

Можете предсказать, как поведёт себя такой Kotlin-код? Скомпилируется ли он, что выведет и почему?

Как бы хорош ни был язык программирования, он может подкинуть такое, что останется только в затылке чесать. Kotlin не исключение — в нём тоже встречаются «паззлеры», когда даже у совсем короткого фрагмента кода оказывается неожиданное поведение.

Ещё в 2017-м мы публиковали на Хабре подборку таких паззлеров от Антона Кекса antonkeks. А позже он выступил у нас на Mobius со второй подборкой, и её мы теперь тоже перевели для Хабра в текстовый вид, спрятав правильные ответы под спойлеры.

Видеозапись выступления также прилагаем, если по тексту что-то окажется непонятным, можете обращаться и к ней.

Первая половина паззлеров ориентирована на тех, кто не очень хорошо знаком с Kotlin; вторая половина — для хардкорных Kotlin-разработчиков. Всё будем запускать на Kotlin 1.3, даже с включенным progressive mode. Исходные коды паззлеров находятся на GitHub. У кого появятся идеи новых, присылайте pull-реквесты.

Паззлер №1

fun hello(): Boolean {
	println(print(″Hello″) == print(″World″) == return false)
}
hello()

Перед нами простенькая функция hello, она запускает несколько print. А мы запускаем саму эту функцию. Простой вопрос для разгона: что она должна напечатать?

a) HelloWorld
b) HelloWorldfalse
c) HelloWorldtrue
d) Не скомпилируется

Правильный ответ

Kotlin puzzlers, Vol. 2: новая порция головоломок - 2

Первый вариант был правильным. Сравнение срабатывает после того, как оба print уже запустились, оно не может запуститься раньше. Почему такой код вообще компилируется? Любая функция, кроме возвращающей Nothing, возвращает что-то. Так как в Kotlin всё — это выражения, то даже return — тоже выражение. Возвращаемый тип return — это Nothing, он приводится к любому типу, поэтому можно так сравнивать. А print возвращает Unit, поэтому Unit можно сравнивать с Nothing сколько угодно раз, и всё классно работает.

Паззлер №2

fun printInt(n: Int) {
	println(n)
}

printInt(-2_147_483_648.inc())

Подсказка, чтобы вы не гадали: страшное число — это действительно минимально возможное 32-битное целое число со знаком.

Здесь всё выглядит просто. В Kotlin есть отличные extension-функции вроде .inc() для инкрементирования. Можем вызвать её на Int, и можем напечатать результат. Что получится?

a) -2147483647
b) -2147483649
c) 2147483647
d) Ничто из перечисленного

Запускаем!

Kotlin puzzlers, Vol. 2: новая порция головоломок - 3

Как видно из сообщения об ошибке, здесь проблема с Long. Но почему Long?

У extension-функций приоритет, и компилятор сначала запускает inc(), а уже затем оператор минус. Если inc() убрать, то это будет Int, и всё будет работать. Но inc(), запускаясь первым, превращает 2_147_483_648 в Long, потому что это число без минуса — это уже не валидный Int. Получается Long, и только потом вызывается минус. Это всё уже нельзя передать в функцию printInt(), потому что она требует Int.

Если мы поменяем вызов printInt на обычный print, который может принимать и Long, тогда правильным будет второй вариант.

Kotlin puzzlers, Vol. 2: новая порция головоломок - 4

Мы видим, что это на самом деле Long. Берегитесь этого: далеко не на все паззлеры можно напороться в реальном коде, но вот на этот можно.

Паззлер №3

var x: UInt = 0u
println(x--.toInt())
println(--x)

В Kotlin 1.3 пришли новые отличные фичи. Кроме финальной версии корутин, мы
теперь наконец-то имеем unsigned-числа. Это нужно, особенно если вы пишите какой-то сетевой код.

Теперь для литералов даже есть специальная буква u, мы можем определять константы, можем, как в примере, декрементировать x и конвертировать в Int. Напоминаю, что Int у нас со знаком.

Что же получится?

a) -1 4294967294
b) 0 4294967294
c) 0 -2
d) Не скомпилируется

4294967294 — это максимальное 32-битное число, которое может получиться.

Запускаем!

Kotlin puzzlers, Vol. 2: новая порция головоломок - 5

Правильный вариант b.

Здесь, как и в предыдущем варианте: сначала на x вызывается toInt(), а только потом декремент. Вывелся результат декремента unsigned, а это максимальное от unsignedInt.

Самое интересное, что если написать вот так, код не скомпилируется:

println(x--.toInt())
println(--x.toInt())

И для меня очень странно, что первая строчка работает, а вторая — нет, это нелогично.

Причем в предрелизной версии правильным вариантом был бы С, так что молодцы в JetBrains, что фиксят баги перед релизом финальной версии.

Паззлер №4

val cells = arrayOf(arrayOf(1, 1, 1),
		arrayOf(0, 1, 1),
		arrayOf(1, 0, 1))

var neighbors = cells[0][0] + cells[0][1] + cells[0][2]
		+ cells[1][0]		   + cells[1][2]
		+ cells[2][0] + cells[2][1] + cells[2][2]

print(neighbors)

На этот случай мы напоролись в реальном коде. Мы в компании Codeborne делали Coding Dojo, вместе имплементировали на Kotlin Game of Life. Как видите, на Kotlin не очень удобно работать с многоуровневыми массивами.

В Game of Life важная часть алгоритма — определение количества соседей для ячейки. Все единички вокруг являются соседями, и от этого зависит, будет ячейка жить дальше или умрёт. В этом коде можно посчитать единички и предположить, что получится.

a) 6
b) 3
c) 2
d) Не скомпилируется

Давайте посмотрим

Kotlin puzzlers, Vol. 2: новая порция головоломок - 6

Правильный ответ — 3.

Дело в том, что плюс с первой строчки перенесён вниз, и Kotlin думает, что это unaryPlus(). В результате суммируются только первые три ячейки. Если мы хотим написать этот код в несколько строчек, нужно перенести плюс наверх.

Это ещё один из «плохих паззлеров». Запомните, в Kotlin не надо переносить оператор на новую строчку, иначе он может посчитать его унарным.

Kotlin puzzlers, Vol. 2: новая порция головоломок - 7

Я не видел ситуаций, когда unaryPlus нужен в реальном коде, кроме DSL. Это очень странная тема.

Это цена, которую мы платим за отсутствие точек с запятой. Если бы они были, было бы понятно, когда заканчивается одно выражение и начинается другое. А без них компилятор должен сам принимать решение. Перевод строки для компилятора очень часто означает, что есть смысл попробовать рассмотреть строки отдельно.

Но есть один очень классный язык JavaScript, в котором тоже можно не писать точки с запятой, и этот код всё равно будет работать корректно.

Паззлер №5

val x: Int? = 2
val y: Int = 3

val sum = x?:0 + y

println(sum)

Этот паззлер засабмитил спикер KotlinConf Томас Нилд.

В Kotlin есть отличная фича nullable types. У нас nullable x, и мы можем его, если он окажется null, конвертировать через Элвис-оператор в какое-то нормальное значение.

Что будет?

a) 3
b) 5
c) 2
d) 0

Запускаем!

Kotlin puzzlers, Vol. 2: новая порция головоломок - 8

Проблема опять в порядке или приоритете операторов. Если мы это зареформатируем, то официальный формат это сделает так:

val sum = x ?: 0+y

Уже формат подсказывает, что сначала запускается 0+y, и только потом x ?:. Поэтому, естественно, остаётся 2, потому что икс равен двум, он не null.

Паззлер №6

data class Recipe (var name: String? = null, var hops: List<Hops> = emptyList() )
data class Hops(var kind: String? = null, var atMinute: Int = 0, var grams: Int = 0)

fun beer(build: Recipe.() -> Unit) = Recipe().apply(build)
fun Recipe.hops(build: Hops.() -> Unit) { hops += Hops().apply(build) }

val recipe = beer {
	name = ″Simple IPA″

	hops {
		name = ″Cascade″
		grams = 100
atMinute = 15
	}
}

Когда меня позвали сюда, мне обещали крафтовое пиво. Я сегодня вечером пойду его искать, еще пока не видел. В Kotlin есть отличная тема — билдеры. Четырьмя строками кода мы пишем свой DSL и потом через билдеры его создаём.

Мы создаем, во-первых, IPA, добавляем туда хмель, который называется Cascade, 100 грамм на 15-й минуте варения, и потом печатаем этот рецепт. Что у нас получилось?

a) Recipe(name=Simple IPA, hops=[Hops(name=Cascade, atMinute=15, grams=100)])
b) IllegalArgumentException
c) Не скомпилируется
d) Ничего из перечисленного

Запускаем!

Kotlin puzzlers, Vol. 2: новая порция головоломок - 9

Мы получили что-то похожее на крафтовое пиво, но в нем нет хмеля, он пропал. Хотели IPA, а получили «Балтику 7».

Здесь произошел naming clash. Поле в Hops на самом деле называется kind, а в строке name = ″Cascade″ мы используем name, который заклэшился с name рецепта.

Мы можем создать свою аннотацию BeerLang и прописать его как часть BeerLang DSL. Теперь мы пытаемся запустить этот код, и он у нас не должен скомпилироваться.

Kotlin puzzlers, Vol. 2: новая порция головоломок - 10

Теперь нам говорят, что в принципе name нельзя из этого контекста использовать. DSLMarker для того и нужен, что внутри билдера компилятор не позволил нам использовать внешнее поле, если у нас внутри есть такое же, чтобы не было naming clash. Код исправляется так, и мы получаем наш рецепт.

Kotlin puzzlers, Vol. 2: новая порция головоломок - 11

Паззлер №7

fun f(x: Boolean) {
	when (x) {
		x == true -> println(″$x TRUE″)
		x == false -> println(″$x FALSE″)
	}
}

f(true)
f(false)

Этот паззлер засабмиттил один из работников JetBrains. В Kotlin есть фича when. Она на все случаи жизни, позволяет писать крутой код, часто используется вместе с sealed-классами для дизайна API.

В данном случае у нас есть функция f(), которая принимает Boolean и что-то печатает в зависимости от true и false.

Что будет?

a) true TRUE; false FALSE
b) true TRUE; false TRUE
c) true FALSE; false FALSE
d) Ничто из перечисленного

Давайте посмотрим

Kotlin puzzlers, Vol. 2: новая порция головоломок - 12

Почему так? Cначала мы вычисляем выражение x == true: например, в первом случае это будет true == true, что означает true. А затем происходит ещё и сопоставление с образцом, который мы передали в when.

И когда x присвоено значение false, вычисление x == true даст нам false, однако в образце будет тоже false — так что пример будет соответствовать образцу.

Исправить этот код можно двумя способами, Один — убрать «x ==» в обоих случаях:

fun f(x: Boolean) {
	when (x) {
		true -> println(″$x TRUE″)
		false -> println(″$x FALSE″)
	}
}

f(true)
f(false)

Второй вариант — убрать (x) после when. When работает с любыми условиями, и тогда не будет сопоставлять с образцом.

fun f(x: Boolean) {
	when {
		x == true -> println(″$x TRUE″)
		x == false -> println(″$x FALSE″)
	}
}

f(true)
f(false)

Паззлер №8

abstract class NullSafeLang {
	abstract val name: String
	val logo = name[0].toUpperCase()
}

class Kotlin : NullSafeLang() {
override val name = ″Kotlin″
}

print(Kotlin().logo)

Kotlin продавали как «null safe»-язык. Представим, что у нас есть абстрактный класс, у него есть какое-то имя, а также property, которое возвращает logo этого языка: первую букву имени, на всякий случай сделанную заглавной (вдруг её забыли изначально заглавной сделать).

Раз язык null safe, мы поменяем имя и, наверное, должны получить корректное logo, которое является одной буквой. Что мы получим на самом деле?

a) K
b) NullPointerException
c) IllegalStateException
d) Не скомпилируется

Запускаем!

Kotlin puzzlers, Vol. 2: новая порция головоломок - 13

Мы получили NullPointerException, который не должны получать. Проблема в том, что сначала вызывается конструктор суперкласса, код пытается проинициализировать property logo и взять у name нулевой char, а в этот момент name равно null, поэтому происходит NullPointerException.

Самый лучший способ это исправить — сделать так:

class Kotlin : NullSafeLang() {
override val name get() = ″Kotlin″
}

Если мы запускаем такой код, мы получаем «K». Теперь базовый класс вызовет конструктор базового класса, он вызовет действительно getter name и получит Kotlin.

Property — отличная фича в Kotlin, но нужно очень аккуратно относиться, когда вы делаете override properties, потому что очень легко забыть, ошибиться или заоверрайдить не то.

Паззлер №9

val result = mutableListOf<() -> Unit>()
var i = 0
for (j in 1..3) {
	i++
	result += { print(″$i, $j; ″) }
}

result.forEach { it() }

Есть mutableList каких-то страшных вещей. Если вам это напоминает Scala, то это не зря, потому что действительно похоже. Есть List лямбд, мы берем два счетчика — I и j, инкрементируем и потом что-то с ними делаем. Что получится?

a) 1 1; 2 2; 3 3
b) 1 3; 2 3; 3 3
c) 3 1; 3 2; 3 3
d) ничто из вышеперечисленного

Давайте запускать

Kotlin puzzlers, Vol. 2: новая порция головоломок - 14

Мы получаем 3 1; 3 2; 3 3. Так происходит, потому что i — переменная, и она сохранит свое значение до конца выполнения функции. А j передаётся уже по значению.

Если бы вместо var i = 0 было бы val i = 0, это работало бы не так, но тогда бы мы не могли инкрементировать переменную.

Здесь в Kotlin мы используем замыкание, этой фичи нет в Java. Она очень крутая, но может нас укусить, если мы не сразу используем значение i, а передаем в лямбду, которая запускается позже и видит уже последнее значение этой переменной. А j передается по значению, потому что переменные в условии цикла — они все равно что val, своё значение уже не меняют.

В JavaScript был бы ответ «3 3; 3 3; 3 3», потому что там ничего не передаётся по значению.

Паззлер №10

fun foo(a:Boolean, b: Boolean) = print(″$a, $b″)

val a = 1
val b = 2
val c = 3
val d = 4

foo(c < a, b > d)

У нас есть функция foo(), берет два Boolean, печатает их, вроде всё просто. И у нас есть куча цифр, осталась посмотреть, какая цифра больше другой, и решить, какой вариант верный.

a) true, true
b) false, false
c) null, null
d) не скомпилируется

Запускаем

Kotlin puzzlers, Vol. 2: новая порция головоломок - 15

Не компилируется.

Проблема в том, что компилятор думает, что это похоже на дженерик-параметры: с<a,b>. Хотя вроде как «c» — не класс, непонятно, почему у него должны быть дженерик-параметры.

Если бы код был таким, он бы отлично работал:

foo(c > a, b > d)

Мне кажется, что это баг в компиляторе. Но когда я подхожу к Андрею Бреславу с любым таким паззлером, он говорит «это потому что парсер такой, не хотели, чтобы он был слишком медленным». В общем, он всегда находит объяснение, почему так.

К сожалению, это так. Он сказал, что это исправлять не будут, потому что парсер в
Kotlin еще не знает про семантику. Сначала происходит парсинг, а потом он дальше передаёт другому компоненту компилятора. К сожалению, так, наверное, и останется. Так что не пишите две такие угловые скобочки и любой код посередине!

Паззлер №11

data class Container(val name: String, private val items: List<Int>)
	: List<Int> by items

val (name, items) = Container(″Kotlin″, listOf(1, 2, 3))
println(″Hello $name, $items″)

Delegate — отличная фича в Kotlin. Кстати, Андрей Бреслав говорит, что это та фича, которую он бы с удовольствием из языка убрал, она ему больше не нравится. Сейчас, возможно, мы узнаем, почему! И ещё говорил, что companion objects некрасивые.

Но data classes — точно красивые. У нас есть data class Container, он берёт себе name и items. А заодно в Container мы реализуем тип items, это List, и все его методы мы делегируем items.

Потом мы используем еще одну крутую фичу — destructure. Мы «деструктурируем» элементы name и items из Container и выводим их на экран. Вроде бы всё просто и понятно. Что получится?

a) Hello Kotlin, [1, 2, 3]
b) Hello Kotlin, 1
c) Hello 1, 2
d) Hello Kotlin, 2

Запускаем

Kotlin puzzlers, Vol. 2: новая порция головоломок - 16

Самый непонятный вариант — d. Он и оказывается верным. Как оказалось, из коллекции items элементы просто пропадают, причем не с начала или с конца, а остаётся только посередине. Почему?

Проблема destructuring в том, что из-за делегации все коллекции в Kotlin тоже
имеют свой вариант destructuring. Я могу написать val (I, j) = listOf(1, 2), и получу эти 1 и 2 в переменные, то есть у List есть имплементированные функции component1() и
component2().

У data class тоже есть component1() и component2(). Но так как второй компонент в данном случае приватный, выигрывает тот который публичный у List, поэтому из List берется второй элемент, и мы здесь получаем 2. Мораль очень простая: don’t do that, не надо так делать.

Паззлер №12

Следующий паззлер это очень страшный. Это засабмиттил человек, который как-то связан с Kotlin, поэтому он знает, что пишет.

fun <T> Any?.asGeneric() = this as? T

42.asGeneric<Nothing>()!!!!

val a = if (true) 87
println(a)

У нас есть функция-расширение на nullable Any, то есть она может быть применена вообще на чём угодно. Это очень полезная функция. Если её ещё нет в вашем проекте, стоит добавить, потому что она может вам закастить всё, что угодно, во всё, что угодно. Потом мы берём 42 и кастим его в Nothing.

Ну, если мы хотим быть уверены, что сделали что-то важное, можно вместо!!! написать !!!!, компилятор Kotlin позволяет такое сделать: если вам не хватает двух восклицательных знаков, пишите хоть двадцать шесть.

Дальше мы делаем if (true), и дальше я сам уже ничего не понимаю… Давайте сразу выбирать, что получится.

a) 87
b) Kotlin.Unit
c) ClassCastException
d) Не скомпилируется

Смотрим

Kotlin puzzlers, Vol. 2: новая порция головоломок - 17

Здесь очень сложно дать логичное объяснение. Скорее всего, Unit здесь получается из-за того, что туда больше нечего запихнуть. Это невалидный код, но он работает, потому что мы использовали Nothing. Мы закастили что-то к Nothing, а это специальный тип, который говорит компилятору, что никогда не должен появиться instance этого типа. Компилятор знает, что если возникает возможность появления Nothing, что невозможно по определению, то дальше можно не проверять, это невозможная ситуация.

Скорее всего, это баг в компиляторе, команда JetBrains даже сказала, что, может быть, этот баг когда-нибудь исправят, это не очень приоритетно. Фишка в том, что мы здесь обманули компилятор из-за этого каста. Если убрать строчку 42.asGeneric<Nothing>()!!! и перестаём обманывать, то код перестанет компилироваться. А если оставляем, компилятор сходит с ума, думает, что это невозможное выражение, и запихивает туда что попало.

Я так это понимаю. Может быть, кто-нибудь когда-нибудь объяснит это получше.

Паззлер №13

У нас есть очень интересная фишка. Можно использовать dependency injection, а можно и без него обойтись, сделать синглтоны через object и круто запустить свою программу. Зачем нужен Koin, Dagger или что-то такое? Тестировать, правда, сложно будет.

open class A(val x: Any?) {
	override fun toString() = javaClass.simpleName
}
object B : A(C)
object C : A(B)

println(B.x)
println(C.x)

У нас есть открытый для наследования класс A, он берёт что-то внутрь себя, мы создаём два object’a, синглтона, B и C, оба наследуются от A и передают туда друг друга. То есть отличный цикл образуется. Потом мы печатаем то, что B и C получили.

a) null; null
b) C; null
c) ExceptionInInitializerError
d) Не скомпилируется

Запускаем

Kotlin puzzlers, Vol. 2: новая порция головоломок - 18

Правильный вариант — C; null.

Можно было бы подумать, что когда инициализируется первый объект, второго ещё нет. Но, когда мы это выводим, то у C не хватает B. То есть получился обратный порядок: компилятор почему-то решил C инициализировать первым, а потом он инициализировал B уже вместе c C. Это выглядит нелогично, логично было бы, наоборот, null; B.

Но компилятор пытался что-то сделать, у него не получилось, он там оставил null и решил нам ничего не кидать. Такое тоже может быть.

Если у Any? в типе параметра убрать ?, то работать не будет.

Kotlin puzzlers, Vol. 2: новая порция головоломок - 19

Можно сказать браво компилятору за то, что когда null был разрешен, он постарался, но не получилось, а если? нет, он кидает нам исключение, что нельзя сделать цикл.

Паззлер №14

В версии 1.3 вышли отличные новые корутины в Kotlin. Я долго думал, как бы придумать паззлер насчет корутин, чтобы его кто-то смог понять. Думаю, для некоторых людей любой код с корутинами — это паззлер.

В 1.3 поменялись некоторые названия функций, которые были в 1.2 в experimental API. Например buildSequence() переименован в просто sequence(). То есть мы можем делать отличные последовательности с функцией yield, бесконечные циклы, и потом можем из этого sequence пытаться что-то достать.

package coroutines.yieldNoOne

val x = sequence {
	var n = 0
	while (true) yield(n++)
}

println(x.take(3))

С корутинами говорили, что можно все крутые примитивы, которые есть в других языках, типа yield, можно делать как библиотечные функции, потому что yield — это suspend-функция, которая может прерываться.

Что же будет?

a) [1, 2, 3]
b) [0, 1, 2]
c) Бесконечный цикл
d) Ничто из перечисленного

Запускаем!

Kotlin puzzlers, Vol. 2: новая порция головоломок - 20

Правильный вариант — последний.

Sequence — ленивая штуковина, и когда мы к ней цепляем take, она тоже ленивая. А вот если добавить toList, вот тогда бы действительно вывелось [0, 1, 2].

Правильный ответ вообще не связан с корутинами. Корутины действительно работают, их легко использовать. Для функции sequence и yield даже не нужно подключать библиотеку с корутинами, все уже в стандартной библиотеке.

Паззлер №15

Этот паззлер тоже засабмитил разработчик из JetBrains. Есть такой адский код:

val whatAmI = {->}.fun
Function<*>.(){}()

println(whatAmI)

Когда я его первый раз увидел, во время KotlinConf, я не смог спать, пытался понять, что это такое. Вот такой криптический код можно писать на Kotlin, так что если кто-то думал, что Scalaz — это страшно, то на Kotlin тоже можно.

Давайте гадать:

a) Kotlin.Unit
b) Kotlin.Nothing
c) Не скомпилируется
d) Ничего из перечисленного

Давайте запускать

Kotlin puzzlers, Vol. 2: новая порция головоломок - 21

Мы получили Unit, который пришел неизвестно откуда.

Почему? Сначала мы присваиваем переменной лямбду: {->} — это валидный код, можно писать пустую лямбду. У неё нет никаких параметров, она ничего не возвращает. Соответственно, она возвращает Unit.

Мы присваиваем переменной лямбду и сразу же пишем extension на эту лямбду, а потом ее же запускаем. По факту она просто резервит Kotlin.Unit.

Потом на этой лямбде можно написать функцию-расширение:

.fun
Function<*>(){}

Она объявляется на типе Function<*>, и то, что у нас сверху, ей тоже подходит. На самом деле это Function<Unit>, но я Unit не писал, чтобы было непонятнее. Знаете, как работает звездочка в Kotlin? Это не то же самое, что вопросик в Java. Она выбирает тот тип, который лучше всего подходит.

В итоге запускаем эту функцию, и она возвращает Unit из {}, потому что она ничего не возвращает, это void-функция. Непонятно, зачем так писать, но можно. Анонимная функция-расширение, которую пишешь и сразу вызываешь — такое тоже бывает.

На этом паззлеры завершаются. В заключение хочу сказать, что Kotlin — классный язык. Если вы iOS-разработчик и сегодня увидели его впервые, то увиденное не значит, что на Kotlin не надо писать!

Если вам понравился этот доклад с Mobius, обратите внимание: следующий Mobius состоится 22-23 мая в Петербурге. Там без Kotlin тоже не обойдётся — доклад «Coroutining Android Apps» поможет не наломать дров при переходе к корутинам. Будет и много другого для мобильных разработчиков (как Android, так и iOS), уже известные подробности о программе — на сайте, и с 1 марта стоимость билетов повысится.

Небольшой лайфхак: если не хотите покупать билет, у вас еще есть шанс попасть на конференцию в качестве спикера — до 6 марта мы принимаем заявки на доклады.

Автор: phillennium

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js