Язык Lua и Corona SDK (2-3 часть)

в 23:50, , рубрики: corona sdk, Lua, статьи для начинающих

image

Это вторая из трех частей статьи о базовых понятиях языка Lua для Corona SDK (если вы не читали первую часть советую начать с нее), в этой части мы рассмотрим все имеющие в языке типы данных. Вы узнаете, как инициализировать переменные всех типов, использовать их значения и преобразовывать один тип в другой. Эта статья является крайне важной в вопросе изучения языка и я постарался добавить большое количество ценных советов, которые позволят вам избежать множества ошибок когда вы будете писать свои игры.

Типы данных и их преобразование

В Lua имеется 8 типов данных. Перед рассмотрение типов остановлюсь на стандартной функции языка lua — type. Эта функция возвращает строку с названием типа данных указанной переменной или данных. Пример использования функции type:

n = nil
st = 'Вася'
pi = 3.14
func = function()end
t = {}
b = true
co = coroutine.create(function ()end)
print(type(n), type(st), type(pi), type(func), type(t), type(b), type(co))
--результат: nil string number function table boolean thread

Далее рассмотрим типы данный языка Lua.

Тип nil

Этот тип данных по умолчанию имеет любая переменная которой не назначено значение. Все переменные типа nil имеют значение nil. Переменной можно присвоить этот тип двумя способами:

local B = nil--явно определяем тип
local A--не явно определяем тип
С--ошибка - для глобальных переменных нельзя не явно инициализировать nil

Стоит заметить, что если переменная не была инициализирована в текущей области видимости ее тип и значение будут nil. Переменная имеющая тип nil может сравниваться в порядке сравнения логической переменной имеющей значение false через логическую операцию not, но в действительности эти типы не аналогичны, приведу пример:

n = nil
b = false
if not n then--true
	print(1)
end
if not b then--true
	print(2)
end
if b == n then--false
	print(3)
end
-->> 1
-->> 2

Тип boolean

Тип boolean так же называют логическим или булевым типом, так как с помощью него производится реализация операций сравнения. Как уже выше писалось boolean имеет всего 2 возможных значения false и true (ложь и правда). Так как все операции сравнения возвращают именно эти значения — переменные этого типа можно инициализировать путем присвоения им значения сравнительной операции, так же можно инициализировать путем прямого назначения значения. Переменные типа boolean можно сравнивать только операциями равенства и неравенства, а так же непосредственно в качестве условия сравнения в if/elseif

--прямая инициализация по значению
b1 = false
b2 = true 
--инициализация через сравнение
b3 = b1 ~= b2--true
b4 = b1 == b2--false
if b1 == b3 then--true
	print(true)
end
if b3 then
	print(true)
end

Тип number

Расширенный численный тип, полное его описание значительно выходит за рамки этой текста, поэтому любознательные пользователи могу проследовать по ссылке и детально ознакомиться со спецификацией. Могу лишь сказать что вместить этот тип может как отрицательные так и положительные числа, как целые так и с плавающей запятой, при определенных уровнях положительной и отрицательной разрядности происходит автоматический переход в экспоненциальную форму чисел, пределы разрядности зависят от разрядности процессора устройства, но в текущий момент Corona SDK имеет поддержку Android 4.0.3+ что скорее всего означает что все имеющиеся устройства будут 64 разрядными. Что бы инициализировать переменные типа number достаточно либо явно присвоить значение в одном из доступных форматов либо присвоить переменной результат вычисления или приравнять ее другой переменной этого типа. Отмечу что любая переменная типа number в операциях логического сравнения возвращает true, даже 0 (и даже -1), это стоит учитывать любителям языка c++ (и подобных) где 0 — это аналог false. Приведу пример операций с типом number:

local a,b  = 2,3--непосредственная инициализация 
local c = a + b--присвоение результату вычисления

--пример экспоненциальной записи
d = 12e5 -- 1 200 000
e = 2e-3 -- 0.002

--пример шестнадцатеричной инициализации
f = 0x234 -- 564
--вещественные числа
j = 1.23
k = .55 -- 0.55 - для чисел меньше 1 ноль в начале не обязательно писать

С переменными типа number можно проводить ряд численных операций, основные из которых показаны в примере:

a = b + c -- сложение
a = b - c --вычитание
a = b * c -- умножение
a = b / c -- деление
a = b % c --получение остатка от деления
a = b ^ c --возведение b в степень c

Тип string

Тип String весьма неплохо развит в Lua и в комплекте с переменными это типа идет множество удобств. Как создавать переменные типа string:

local st1 = 'qwert'--присвоение строки
 st2 = st1--приравнивание другой переменной
st3 = st1 .. st2--результат сложения двух строк

Как вы заметили из примера сцепление(конкатенация) строк осуществляется двумя точками, причем, сам процесс конкатенации может использоваться как один из способов конвертации числа в строку, приведу пример:

i = 10--number
st = '11'--string
konc1 = 1 .. st -- конкатенация
konc2 = 100..''--сцепление с пустой строкой переводит переменную в string
print(konc1, konc2, type(konc1) ,  type(konc2 )-->>111  100  string string

Как видите перевод из number в string достаточно простой. Хочу обратить внимание, что в случае если вы используете сцеплении строки лучше всего между оператором сцепления и операндами ставить пробел, это не всегда обязательно, но лучше привыкнуть так как во-первых так красивей и понятней, а во-вторых в случае если один из операндов — число (не переменная равная числу, а именно число), то если не поставить пробел возникнет ошибка "malformed number near". Есть и более традиционные подходы к конвертированию, приведу пример перевода в обе стороны:

i1 = 1
s1 = '11'
i2 = tonumber(s1)--перевод string -> number
s2 = tostring(i1)-- перевод number -> string

Так же хочется добавить, что неявное конвертирование string в number то же существует, но категорически не советую вам так делать — это совершенно безобразно и почти наверняка когда-нибудь приведет к сложной ошибке:

--пример того как не стоит делать
st = '10'
i = 10 + st
print(i, type(i))-->> 20  number

При инициализации переменных типа string не редко могут возникать сложные моменты, которые я кратко опишу и приведу способы их решения:

  • В строке имеется символ двойных или одинарных кавычек — это решается одним из двух способов: либо необходимо перед символом поставить слешь "", это действие экранирует символ не давая ему завершать открытую кавычку, либо использовать другой вид кавычек, в Lua в качестве символа открытия и закрытия строки можно использовать как одинарные так и двойные кавычки.
  • В строке имеется символ слешь "" это вызывает трудности как как интерпретатор считает что символ стоящий посте слеша — управляющий, что бы решить эту проблему необходимо ввести еще один слешь и строка "\" будет считаться как будто символ 1

Как я выше писал string содержит массу полезных удобств, они реализованы с помощью функций которые мы рассмотрим ниже.

string.len(s) / s:len()

Функция позволяет определить длину строки. Эту как и большинство других функции из библиотеки string можно применять двумя способами, но имеются особенности, ниже приведу пример использования:

--полная запись string.len(s)
local st = 'Hello World!'
local len_st = string.len(st)
print(len_st)-->>12

--сокращенная запись s:len()
local le_st = st:len()
print(len_st)-->>12

--определение длины без переменной
print(string.len('Examlpe string'))-->>14

--так будет ошибка - нельзя использовать сокращенную 
--запись для определения длины без переменной
print('Examlpe string':len())-->> Error: ')' expected near ':'

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

string.byte(s [, i [, j]]) / s:byte([i [, j]])

Функция возвращает коды символов строки от i-го до j-того. Параметры i и j не являются обязательными и если не указать j будет возвращен только код i-того элемента, если не указать i, но указать j, будут возвращены коды символов с первого по j-тый, если не указать не i не j будет возвращен код первого символа в строке. Приведу пример использования функции:

local s = 'Example'
print(s:byte())--первый символ >> 69
print(string.byte(s, 3))--третий символ >> 97
print(string.byte(s,nil, 4))--с первого по 4-й >> 69  120  97  109
print(s:byte(nil, s:len()))--все символы >> 69  120  97  109  112  108  101

string.char(i1, i2, ...)

Функции передается один или более параметров с номерами символов, возвращается строка равная всем переданным символам. Эта функция имеет обратное назначение относительно функции string.char(). Примеры использования:

print(string.char(65)) -->> A
print(string.char(72,101,108,108,111)) -->> Hello

local s = 'Example'
print(string.char(s:byte(1,s:len())))--извлекаем s:byte все символы 
---строки st и заново их собираем в исходную строку >> Example

string.find(s, pattern [, index [, no_regular]])
s:find(pattern [, index [,no_regular]])

С помощью функции find можно найти в строке s позицию шаблона pattern или nil если не найдено. Шаблон pattern может быть как строкой так и регулярным выражением (подробно о регулярных выражениях вы узнаете в отдельной главе). Рассмотрим простейший вариант применения find далее перейдем к особенностям необязательных параметров:

local s = "Hello Lua!"
print(string.find(s,"Lua"))--ищем с какой по какую позицию 
--находится строка Lua в строке s >> 7  9
print(s:find("%sL"))--это регулярное выражение означает "Искать 
--букву L перед которой имеется любой знак разделения слов" >> 6  7

Параметр index по умолчанию равен 1 и это означает, что возвращается позиция первого найденного вхождения если искать с начала, если установить index равным к примеру 3, то будет возвращаться третье вхождение в строку начиная с начала, если же -3 то будет искаться 3 вхождение с конца строки. Параметр no_regular — логический, т.е. может принимать значения истина/ложь (true/false), по умолчанию параметр равен false, если же установить true то параметр pattern всегда будет восприниматься как обычная строка, даже если ввести в него регулярное выражение. Приведу пример расширенного применения find:

s = 'La La La La %sLa La'
print(s:find('La', 2)) --второе вхождение с начала >> 4  5
print(s:find('La', -2)) --второе вхождение с конца >> 18  19
print(s:find('%sLa', 1, true)) -->> игнорировать регулярку, искать как строку >> 13  16

string.format(s, e1, e2, ...)
s:format(e1, e2, ...)

Функция string.format создает форматированную строку с набором аргументов. На первый взгляд эта функция может показаться сложной в понимании, но на самом деле она может сильно упрощать многие конструкции при выводе конечной информации, а так же весьма удобно применяется для других косвенных преобразований, например из number в строку шестнадцатеричного кода. Приведу простейший пример:

local sum1,sum2 = 100,200-- выводимые значения
print(string.format('Value1: %d, Value2: %d', sum1,sum2))-->> Value1: 100, Value2: 200

Как видите, сначала идет формат строки, в котором содержатся аргументы показывающие каким образом нужно использовать параметры идущие следом за этим параметром. Существует 11 (c, d, E, e, f, g, i, o, u, X, x) аргументов предназначенных для управления числовыми значениями и 2(s,q) аргумента для управления строками. Далее кратко остановлюсь на каждом аргументе после чего приведу примеры использования:

  • %s — параметр считается строкой и просто вставляется
  • %q — параметр считается строкой и вставляется в двойных кавычках
  • %c — вместо числа будет подставлен символ номер которого в соответствующем параметре
  • %d — просто будет вставлено переданное число без изменения
  • %i — число представляется как целое число со знаком Signed типа integer
  • %u — число представляется как целое число без знака Unsigned типа integer
  • %o — число переводится в восьмеричную систему счисления
  • %x,%X — число переводится в шестнадцатеричную систему счисления, регистр букв в строке будет зависеть от регистра аргумента
  • %f,%g — число считается вещественным (с плавающей запятой), для %f формат полный, для %g укорочен до 5 знаков после запятой
  • %E,%e — число будет преобразовано в экспоненциальную форму, регистр буквы E в строке будет зависеть от регистра аргумента

print(string.format('Number: %d, Signed: %i, Unsigned: %u', 100,-100,-200)) -->> Number: 100, Signed: -100, Unsigned: 4294967096
print(string.format("%c%c%c%c%c%c%c", 69,120,97,109,112,108,101)) -->> Example
print(string.format("%e, %E", math.pi,math.pi))-->> 3.141593e+000,  3.141593E+000
print(string.format("%f, %g", math.pi,math.pi))-->> 3.141593,  3.14159
print(string.format("%o, %x, %X", 1024,4069,16382)) -->> 2000, fe5, 3FFE
print(string.format("%s %q", "Hello", "Corona SDK!"))-->> Hello "Corona SDK!"

string.lower(s) / s:lower()

Функция переводит все символы строки в нижний регистр:

print(string.lower('Hello World!')) -->> hello world!

string.upper(s) / s:upper()

Функция переводит все символы строки в верхний регистр:

print(string.upper('Hello World!')) -->>HELLO WORLD!

string.rep(s, n) / s:rep(n)

Функции передается строка и число n, возвращается строка повторенная n раз:

local s = 'Corona '
print(s:rep(3))-->> Corona Corona Corona

string.reverse(s) / s:reverse()

Функция возвращает переданную строку с символами в обратном порядке:

print(string.reverse('9876543210')) -->0123456789

string.match (s, pattern [, index])
s:match(pattern [, index])

Функция string.match находит вхождение в строке s согласно регулярного выражения pattern и возвращает параметры захвата. О захватах с помощью регулярных выражениях будет подробно описано в соответствующем разделе. В функции имеется необязательный параметр index который по умолчанию равен 1 и определяет из какого по счету вхождения в шаблон необходимо сделать захват, стоить заметить что отрицательное значение Index по аналогии с string.find не поддерживается:

local reg = " (%w+):(%d+)"--регулярное выражение с захватом 2 параметров
local s = " force:30, speed:25"--строка для поиска и захвата
local key, value = s:match(reg, 2)--захват во втором вхождении
print(key, value)-->> speed  25

string.gmatch(s, pattern)
s:gmatch(pattern)

Функция string.gmacth во многом аналогична функции string.macth, но она предназначена для применения в качестве аргумента в цикле(более подробно о циклах будет рассмотрено в соответствующем разделе). string.gmacth циклически возвращает захваты от всех вхождений в строке, т.е. не требуется указывать index, выглядит это как на следующем примере:

local reg = " (%w+):(%d+)"--регулярное выражение с захватом 2 параметров
local s = " force:30, speed:25"--строка для поиска и захвата
for key, value in s:gmatch(reg) do--цикл с захватом всех вхождений
	print(key, value)
end
-->> force  30
-->> speed  25

Циклический захват — это мощнейший инструмент Lua умение пользоваться которым, может значительно облегчить многие рутинные операции по разбору сложных строчных значений.

string.sub(s, i [, j])
s:sub(i [,j])

Функция string.sub возвращает подстроку переданной строки. Подстрока начинается с i-го символа. Если третий аргумент j не указан, подстрока будет заканчиваться в конце строки. Если указан третий аргумент, подстрока заканчивается j-тым символом. Если i или j имеет отрицательное значение счет идет с конца строки. Приведу примеры использования:

local s = 'To be or not to be'
print(s:sub(7))-->>or not to be
print(s:sub(7,12))-->>or not
print(s:sub(-5))-->>to be
print(s:sub(4,-3))-->>be or not to
print(s:sub(-8,-4))-->>ot to

string.gsub(s, pattern, replace [, n])
s:gsub(pattern, replace [,n])

Функция string.gsub одна из мощнейших функций языка. Функция имеет несколько вариантов применения в простейшем случае она заменяет в строке s все совпадения указанные в pattern на значение указанное в replace во всех местах, с помощью n можно ограничить число замен, функция вернет результирующую строку и количество замен:

local s = 'dust ist fantastisch'
print(s:gsub('s','S'))-->>duSt iSt fantaStiSch	4

local s = 'lalalalala'
print(s:gsub('a','A',3))-->>lAlAlAlala	3

Как и в случае с string.find в качестве шаблона поиска может быть использовано регулярное выражение (захват). Аргументы захвата можно размещать согласно индексов %номер_индекса:

print(string.gsub("lalalalala", "(l)(a)", "%2%1")) --захват символов l и a
--далее вставка их в обратном порядке -->> alalalalal	5

В качестве параметра replace может быть использована функция при этом захваченные регулярным выражением параметры будут передавать ей в качестве параметров.

string.gsub("12 23 34", "(%d+)", print)--захват всех отдельно стоящих чисел
-->> 12
-->> 23
-->> 34

Тип table

Тип table (таблица) является самым основным типом представления данных в Lua, в самом общем смысле почти все основные сущности в языке являются таблицами, даже файлы исходных текстов. Как создавать таблицы и как с ними работать:

local t = {}--создана пустая таблица
t['key1'] = 'name1'--добавлен параметр key1 со зданием "name1"
t['key2'] = {}--добавлен параметр key2 являющийся таблицей
t['key2']['key3'] = 123--добавлен параметр key3 в таблицу key2

точно такую же таблицу можно инициализировать другим способом:

local t = {
	key1 = 'name1',
	key2 = {
		key3 = 123,
	},
}

заметьте эти записи идентичный:

t['key1'] = 'name1'
t.key1 = 'name1'

смысл в первом типе записи имеется только в том случае если в качестве ключа используется числовое значение:

t[1] = 111--все верно
t.1 = 111--ошибка так делать нельзя

Значения записанные в таблице доступны для чтения и записи:

print(t.key1)-->> name1
t.key1 = 'name2'
print(t.key1)-->> name2

Что бы очистить все содержимое таблицы можно воспользоваться одним из способов:

t = {}--содержимое таблицы очищено - это не лучший способ
table.remove(t)--это хороший способ очистки таблицы

Если вы хотите удалить один из ключей таблицы это можно сделать так же двумя способами:

t.key1 = nil--присвоение nil удаляет ключ
table.remove(t, key1)--это лучший способ очистки ключа

Наиболее естественным способом инициализации таблиц в Lua счтается создание нумерованного массива, это делается следующим способом:

local t = {23, 45, 3.14, 'vasia', {12, 'a'}}

На самом деле если не указывать ключ при инициализации таблицы происходит нумерование в порядке увеличения начиная 1 (для большинства разработчиков более естественна нумерация с 0, но в Lua это не так, нужно об этом помнить), т.о. инициализированная таблица на самом деле имеет следующий вид:

local t = {
	[1] = 23,
	[2] = 45,
	[3] = 3.14,
	[4] = 'vasia',
	[5] = {
		[1] = 12,
		[2] = 'a',
	},
}

Как видите «под капотом» с данными произошли достаточно существенные преобразования. У нумерованных таблиц в Lua имеются особые свойства, например добавив к имени таблицы символ # вы получаете количество строго нумерованных значений в таблице:

print(#t)-->>5
print(#t[5])-->>2 (размер вложенной таблицы с ключом 5)

Если добавить к таблице новый строго нумерованный ключ получаемый через # размер таблицы увеличится:

t[#t+1] = 'kolia'
print(#t)-->> 6

Если удалить один из ключей путем присвоения nil например ключ [3] непрерывная часть нумерованного массива уменьшится всего до 2 ключей [1] и [2], что не может привести к дальнейшим ошибкам из-за неверного понимания, по этой причине выше я советовал удалять ключи из таблицы с помощью table.remove так как при этом если удалить ключ остальные строгонумерованные значения будут смещены:

--неверное удаление ключа
t[3] = nil
print(#t)-->> 2
--верное удаление ключа
table.remove(t,3)
print(#t)-->> 5

После правильного удаления ключа таблица будет иметь вид:

local t = {
	[1] = 23,
	[2] = 45,
	[3] = 'vasia',
	[4] = {
		[1] = 12,
		[2] = 'a',
	},
	[5] = 'kolia',
}

вы можете добавить в строгонумерованную таблицу другие ненумерованные ключи, на работоспособность таблицы это не повлияет, но эти ключи так же не будет учитываться при получении размера через #:

t.key = 'name1'
print(t.key, #t)-->> name1  5

Для обхода всех нумерованных значений таблицы циклической операцией имеется функция ipairs, она используется в качестве аргумента цикла for по аналогии с string.gmatch:

for key, value in ipairs(t) do
	print(key, value)
end
-->> 1  23
-->> 2  45
-->> 3  vasia
-->> 4  table: 00000000
-->> 5  kolia

Если же вы хотите обойти циклом все значения включая не строго нумерованные используйте функцию pairs:

for key, value in pairs(t) do
	print(key, value)
end
-->> 2  45
-->> key  name1
-->> 3  vasia
-->> 1  23
-->> 4  table: 00000000
-->> 5  kolia

Обратите внимание? что при использовании pairs последовательность вывода значений не строгая, а в общем-то довольно запутанная, это нужно учитывать. В заключении хочу отметить что использовать pairs/ipairs вы можете как передавай значение таблицы, так и создавая ее непосредственно в вызов примерно так:

for key, value in ipairs{10,20,30} do
	print(key, value)
end
-->> 1  10
-->> 2  20
-->> 3  30

for key, value in pairs{key1 = 34,key2 = 65, key3 = 12} do
	print(key, value)
end
-->> key1  34
-->> key3  12
-->> key2  65

Тип function

Тип function имеют все созданные пользователем функции и стандартные функции языка. Приведу пример:

local summ = function(a,b)
	return a + b
end
print(type(summ))-->> function
print(type(print))-->> function

Более подробно о порядке применения функций вы узнаете в соответствующем разделе.

Тип thread

Тhread — это отдельная нить выполнения кода. Попробую кратно пояснить что это такое. Весь код вашего проекта на Lua выполняется в едином нити выполнения, т.е. если какая-то операция потребует какого-то времени выполнения вся ваша игра на это время остановится и как бы застынет — это крайне плохо сказывается на комфорте применения ваших игр. Приведу пример весьма распространенной проблемы которую можно решить с помощью thread. У вас есть свой сервера и на нем имеется некоторый скрипт с помощью которого на сервер можно загружать файлы, процесс загрузки может занимать какое-то время в этим моменты ваше приложение будут подвисать, проблемный код будет выглядеть примерно так:

----------------------------------
-- Отправка файла на ваш сервер --
----------------------------------

local ip_server = 'http://128.0.0.1/'--ip вашего сервера
local file_name = "object.json"--имя файла
local directiry_file = system.DocumentsDirectory--каталог файла

--функция слушатель результата выполнения запроса
local function networkListener( event )
    if ( event.isError ) then
        print( "Network error: ", event.response )
    else
        print ( "Upload complete!" )
    end
end

local params = {--параметры запроса
	headers = {--настройки заголовка
		["Content-Type"] = "application/json"
	}
	body = {
		filename = file_name,
		baseDirectory = directiry_file,
	},
}

--выполняем отправку файла на сервер
network.request( ip_server.."load.php", "POST", networkListener, params )

Теперь создадим нить выполнения, что бы в последствии перенести туда проблемный участок кода:

--содержимое этой функции выполнится в отдельной нити
local co_finction = function()
	--ваш тормозящий код
end

local co = coroutine.create( co_finction )--создаем thread
print(type(co))-->> thread
coroutine.resume( co )--стартуем co_finction в нити

Теперь network.request перенесем в в тело функции co_finction и получим код который работает точно так же но совершенно не завешивает вашу игру:

----------------------------------
-- Отправка файла на ваш сервер --
----------------------------------

local ip_server = 'http://128.0.0.1/'--ip вашего сервера
local file_name = "object.json"--имя файла
local directiry_file = system.DocumentsDirectory--каталог файла

--функция слушатель результата выполнения запроса
local function networkListener( event )
    if ( event.isError ) then
        print( "Network error: ", event.response )
    else
        print ( "Upload complete!" )
    end
end

local params = {--парметры запроса
	headers = {--настройки заголовка
		["Content-Type"] = "application/json"
	}
	body = {
		filename = file_name,
		baseDirectory = directiry_file,
	},
}

--содержимое этой фунскции выполнится в отдельной нити
local co_finction = function()
	--выполеняем отправку файла на сервер
	network.request( ip_server.."load.php", "POST", networkListener, params )
end

local co = coroutine.create( co_finction )--создаем thred
print(type(co))
coroutine.resume( co )--стартуем co_finction в нити

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

Тип userdata

Тип userdata используется в Lua для связки исходников этого языка с исходными текстами написанными на других языках, в первую очередь C(си). Т.е. сложные пользовательские структуры оформленные в С в Lua принимаются как userdata. На уровне этой статьи этот тип нам не пригодится, но вы всегда можете самостоятельно изучить вопрос.

Заключение

Вы стали еще на шаг ближе к освоению языка Lua и теперь вы сможете уже уверено читать и понимать большую часть исходников которые вам встретятся при рассмотрении примеров, которые поставляются вместе с дистрибутивом Corona SDK, но все же это не все и я рекомендую изучить последний из трех уроков.

Автор: Денис Гончаров

Источник

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


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