Мой опыт с резиновым мужиком. Github Copilot
Первая моя ассоциация с Github Copilot — это резиновый мужик-автопилот из фильма «Аэроплан». Помню, в детстве я увидел этот фильм и потом реально думал, что так автопилоты и выглядят: какая-то смешная резиновая кукла надувается и начинает управлять самолётом. И вот, через столько лет меня не покидает ощущение, что передо мной надувается какая-то кукла и пытается писать за меня код.
Короче, нежданно-негаданно мне пришёл инвайт в GitHub copilot. Я и забыл про то, что когда-то запрашивал доступ — ажиотаж давно уже спал, вроде мир не захвачен машинами, а значит, не такой уж он и крутой, да? С другой стороны, недавно к нам на позицию senior python постучал чел и сказал, что большую часть нашего тестового задания он запилил при помощи copilot, так что определённо кто-то «пилотом» пользуется. В общем, непонятно: хорошо оно или плохо? Стану ли я теперь более продуктивным? Есть только один способ проверить: в бою.
Как тестировать?
И тут я задумался.
Реальные проекты разные.
В copilot ты пишешь комментарий или код, и по нему работает автодополнение. И вот тут мне показалось плохой идеей писать что-то вроде:
# this is a bullshit code written by previous developer,
# zero tests (of course), please run it and see where it breaks
# and try to fix; also update all dependencies to latest
# (read changelogs and migrate db and code where needed)
Нет, ну конечно, я попробовал, но мужик написал мне только
def bullshit_code():
Короче, такие абстрактные вещи для copilot не подходят. Лучший кейс, как я понял — создание какой-нибудь функции, класса, метода или сниппета кода. Чего-то маленького, с ограниченной логикой.
Давайте всё это и попробуем. А под конец узнаем, кто всё-таки будет писать код: кожаный мешок или резиновый мужик.
Сразу предупрежу, что мужик может выдавать до 10 вариантов в режиме «выбирай что тебе больше нравится», но я буду больше всего внимания уделять первым ответам в списке, потому что
первый вариант в списке — это вариант с наибольшей вероятностью (а copilot, как и gpt, просто предсказывает наиболее вероятное продолжение текста/кода), и значит у мужика этот первый ответ в приоритете (сугубо моё предположение)
необходимость выбирать из 10 вариантов напрочь убивает всю идею помощника; если я должен читать 10 сниппетов кода и искать лучший, то я лучше и быстрее напишу сам
Мини-функции
Иногда нужно написать что-нибудь простое и раздражающее. Какую-нибудь мелкую функцию, которая по какой-то причине не попала в огромную stdlib. Мне это всегда было скучно писать, поэтому будет здорово, если у меня заберут эту рутину.
Разбитие по чанкам
Я прям беру описание из more_itertools: chunked
Break iterable into lists of length n
def chunks(lst, n):
for i in range(0, len(lst), n):
yield lst[i:i + n]
def chunks(lst, n):
return [lst[i:i+n] for i in range(0, len(lst), n)]
Два варианта, один генератор, другой просто возвращает список. Это годно!
Lookahead без повреждения генератора
Тоже из more_itertools: spy
Return a 2-tuple with a list containing the first n elements of iterable, and an iterator with the same items as iterable. This allows you to «look ahead» at the items in the iterable without advancing it.
По факту мы должны создать кэш из n
первых элементов, вернуть этот кэш и также вернуть «восстановленный» генератор.
def spy(iterable, n=1):
it = iter(iterable) # о! превращаем iterable в iterable :)
cache = []
for i in range(n):
try:
cache.append(next(it))
except StopIteration:
break
return cache, it
Вообще не то! Тут вроде есть cache
, и возвращается даже правильная структура, но внутри у copilot произошла шизофазия. Должно быть что-то такое:
from itertools import chain, islice
def spy(iterable, n=1):
head = list(islice(iterable, n))
return head, chain(head, iterable)
Самая частая буква
Как-то на тестовом задании мне нужно было вернуть наиболее часто встречающуюся букву. Решение я родил за 5 секунд:
from collections import Counter
def most_common_letter(text: str) -> str:
return Counter(text).most_common(1)[0][0]
Сначала мужик предлагает мне для каждого символа из текста вызывать text.count(symbol)
:
def most_common_letter(text: str) -> str:
return max(text, key=text.count)
Я начинаю возникать, мол, может будем экономить ресурсы? Мужик предлагает: давай вызывать text.count(symbol)
для каждого символа не более одного раза:
def most_common_letter(text: str) -> str:
return max(set(text), key=text.count)
Может, встроенный Counter
поюзаем?
«Окей,» — говорит мне копилот, — «вот тебе твой Counter
»:
def most_common_letter(text: str) -> str:
import collections
return max(collections.Counter(text).items(), key=lambda x: x[1])[0]
Но Counter
сам умеет считать максимум, а ты юзаешь max
!
def most_common_letter(text: str) -> str:
import collections
return collections.Counter(text).most_common(1)[0][0]
Итого 4 варианта, из них два говно, два приемлимы.
RLE (run-length encoding)
Яндекс на собеседовании просил написать RLE функцию. Смог бы copilot работать в Яндексе?
# RLE (run-length encoding) function
def rle(string: str) -> str:
result = ''
for i in range(len(string)):
if i == 0:
result += string[i]
elif string[i] == string[i - 1]:
result += '*'
else:
result += string[i]
return result
# In [21]: rle('aaabcccd')
# Out[21]: 'a**bc**d'
Вообще не то! Кажется, мы только что убедились, что Copilot не умеет гуглить.
Я бы написал так:
from itertools import groupby
def rle(string):
result = ''
for char, group in groupby(string):
result += f'{char}{len(list(group))}'
return result
# In [35]: rle('aaabcccd')
# Out[35]: 'a3b1c3d1'
Генератор паролей
# generate random password
def generate_random_password(length: int) -> str:
import random
import string
return ''.join(
random.choice(string.ascii_letters + string.digits)
for _ in range(length)
)
Вроде ничо, но только copilot забыл, что random не настоящий рандом, и для паролей и прочих секретов есть secrets
:
# use secrets to generate random password
def generate_random_password(secrets: str) -> str:
import secrets
return secrets.token_urlsafe(10)
Оверкилл. Я ожидал просто:
import secrets
password = secrets.token_urlsafe(length)
Генератор умножения на 2
# iterate over large list of numbers and multiply each number by 2
def double_numbers(numbers: list) -> list:
return [x * 2 for x in numbers]
Я написал «iterate» и «large list», но мужик намёков не понял и возвращает список. Ну такое.
Сгруппируй слова
# group words that consist of same letters
def group_words(words: list) -> list:
result = []
for word in words:
if word not in result:
result.append(word)
return result
Вместо того, что просили, копилот изобрёл уродливую версию list(set(words))
, то есть просто вернул уникальные слова.
Удали элемент
# remove second element from list
def remove_second(lst: list) -> list:
lst.pop(1)
return lst
А вот это говно. Мужик передаёт мутабельный объект, модифицирует его и его же и возвращает.
Посмотрите, как сделано в питоне: reversed()
возвращает копию и не модифицирует оригинал, а .reverse()
ничего не возвращает и модифицирует оригинал.
Посмотрите, как сделано в pandas: apply()
возвращает копию и не модифицирует оригинал, а с параметром inplace=True
ничего не возвращает и модифицирует оригинал.
Должно быть что-то одно.
Что я понял
После большинства функций я выглядел так:
Тут ко мне пришло понимание, что для надувного мужика важно, чтобы ТЗ было чётким, а не как мы с вами привыкли: «напиши чонить шоб работало как надо». По опыту знаю, что документация и ТЗ могут отнимать много времени, так имеет ли смысл писать комментарий длиннее самой функции? Вот что надо написать, чтобы мужик сгенерировал годноту:
# function which receives password length and generates random password
# of specified length; uses "secrets" module
def generate_random_password(length: int) -> str:
import secrets
return secrets.token_urlsafe(length)
Длинные функции
А давайте чтобы мужик писал больше, чем я? А то нечестно.
Класс мне запили
Сделай мне человека!
# person class
class Person:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
def __str__(self):
return f'{self.name}, {self.age}'
Алё! Ты из криокамеры вылез? Где датаклассы?
# person dataclass
@dataclass
class Person:
name: str
age: int
Так-то! Мы сделаем из тебя человека! Вообще можно ещё сделать @dataclass(slots=True, kw_only=True)
, но copilot мне не платит за обучение (но вы можете), так что учить я его не буду.
Просто сделай запрос
# makes async requests to url
async def make_request(url: str):
import requests
response = requests.get(url)
return response.text
Тут бы какой-нить httpx…
Читай файл с комментариями
# read file exluding blank lines and lines starting with "#"
def read_file_exluding_blank_lines_and_lines_starting_with_hash(file: str) -> list:
with open(file, 'r') as f:
return [line for line in f if line and not line.startswith('#')]
А я-то думал, что это у меня проблемы с названиями для функций…
Опять же, почему не генератор? Файлы бывают большие.
Прочитай файл по кусочкам
# read file by chunks of 5 lines
def read_file_by_chunks(file: str) -> list:
with open(file, 'r') as f:
return [f.readlines(5)]
Не знаю, где и кто надувал этого мужика, но про генераторы он явно не слышал. А ещё он делает полную, тотальную хрень в этом примере: возвращает список, где единственый элемент — это первые 5 строчек файла.
Dict в файл
# write dictionary to file
def write_dictionary_to_file(dictionary: dict, file: str):
with open(file, 'w') as f:
for key, value in dictionary.items():
f.write(f'{key} {value}\n')
Честно, я б не додумался записывать словарь как строчки ключ значение
! А если ключ с пробелом, то как отличить ключ от значения: ключ хзчто значение
?
Давайте намекнём на json:
# write dictionary to json file
def write_dictionary_to_json_file(dictionary: dict, file: str):
import json
with open(file, 'w') as f:
json.dump(dictionary, f)
Почти!
file
на самом деле надо назватьfile_name
а вот
f
должен быть как разfile
и сдампил бы я с
indent=2
хотя бы, чтобы человек тоже мог прочитать
Поменяй колонки в csv файле
# swaps 1st and 2nd csv columns
def swap(csv: str) -> str:
return csv.replace(',', ';')
Ну ПОЧЕМУ?! За что?
Я попытался рассказать мужику про csv файлы, но он всё равно говорит, что поменять колонки — это заменить ,
на ;
:
# swaps 1st and 2nd columns in csv file
def swap(infile: str, outfile: str):
with open(infile, 'r') as f:
lines = f.readlines()
with open(outfile, 'w') as f:
for line in lines:
f.write(line.replace(',', ';'))
Live coding
Ладно, вся эта синтетическая хрень довольна показательна, но вдруг ему просто не хватает контекста? Давайте-ка бросим мужика в реальный проект, пусть покажет, на что способен! Может теперь он будет кодить по ночам вместо меня, зарабатывать деньги и кормить семью, а я наконец-то пройду GTA V?
Далее будут просто куски кода, когда пилот что-то предлагал, а я посчитал это интересным.
Бесполезные действия
def test_limited_plan_duration(db, user, plan, now, days):
# ...
for _ in range(10):
# copilot:
subscription.end = subscription.start + days(10)
subscription.save(update_fields=['end'])
subscription.start = subscription.end
Достаточно странно: subscription
— это подписка, и copilot сдвигает её на 10 дней вперёд, но вот сохраняется только поле end
. Зачем тогда менять start
— не понятно.
Угадывает копипасту
subscription.prolong()
assert subscription.end == now + 2 * plan.charge_period
subscription.prolong()
# copilot:
assert subscription.end == now + 3 * plan.charge_period
Тут он неплохо прочувствовал, что prolong()
ведёт к увеличению subsctiption.end
. Успех!
Else
if self.end == self.max_end:
raise ProlongationImpossible('Current subscription end is already the maximum end')
# copilot:
else:
Не знаю, кто научил мужика, но в таких ситуациях else
не нужен. Его использование вводит дополнительный уровень отступов, что излишне.
Rest framework
class LimitsView(GenericAPIView):
# copilot:
permission_classes = IsAuthenticated,
schema = AutoSchema()
def get(self, request, *args, **kwargs) -> Response:
return Response(
{
'limits': {
'subscriptions': request.user.subscriptions.count(),
'subscriptions_limit': request.user.subscriptions_limit,
}
},
)
Во-первых, по названию класса copilot сгенерировал мне рабочий endpoint. Впечатляет!…
С другой стороны, тут нету никакого класса для сериализации (serializer_class
), и Response
создаётся ручками. Вроде работает, но swagger, например, автоматически не сгенерировать.
Test class
Вот это впечатляет! Смотрите, только по первой строчке появляется вот что:
class UninitializedAPIBaseConsoleToolTest(BaseConsoleToolTest):
# copilot:
"""
Base class for tests that need an uninitialized API
"""
def setUp(self):
super(UninitializedAPIBaseConsoleToolTest, self).setUp()
self.b2_api = B2Api(self.account_id, self.master_key)
Даже докстринг какой-то появился! Можно придраться к old-style вызову super(Class, self).setUp()
вместо простого super().setUp()
, но не критично.
А теперь идём в базовый класс и смотрим, что там:
class BaseConsoleToolTest(TestBase):
RE_API_VERSION = re.compile(r"\/v\d\/")
json_pattern = re.compile(r'[^{,^\[]*(?P{.*})|(?P\[.*]).*', re.DOTALL)
def setUp(self):
self.account_info = StubAccountInfo()
self.b2_api = B2Api(
self.account_info, None, api_config=B2HttpApiConfig(_raw_api_class=RawSimulator)
)
self.raw_api = self.b2_api.session.raw_api
(self.account_id, self.master_key) = self.raw_api.create_account()
То есть super().setUp()
под капотом создаёт self.b2_api
, а потом мы его пересоздаём, причём если посмотреть в исходники, то вторым аргементом должен быть cache
, а мы туда передаём self.master_key
.
А выглядело так классно!
Lazy callable
@dataclasses.dataclass
class Defer:
callable_: Callable
# copilot:
args: Tuple[Any, ...] = ()
kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict)
def __call__(self):
# copilot:
return self.callable_(*self.args, **self.kwargs)
Вот тут огненно! Кажется, питонисты часто пишут такой lazy-evaluation класс, и copilot отлично пишет весь бойлерплейт, и даже правильно использует default_factory
!
Копипаста
@dataclass
class ReplicationRule:
...
@classmethod
def from_dict(cls, value_dict: dict) -> 'ReplicationRule':
# copilot:
return cls(
destination_bucket_id=value_dict['destinationBucketId'],
file_name_prefix=value_dict['fileNamePrefix'],
is_enabled=value_dict['isEnabled'],
priority=value_dict['priority'],
replication_rule_name=value_dict['replicationRuleName'],
)
Это годно! Мужик где-то нашёл маппинг external field <-> class field
и использовал его, причём сам понял, какие атрибуты нужно задать.
Знает больше, чем я
Писал я себе код, и тут
profile = 'profile-for-test-' # copilot: + random_hex(6)
Я хотел написать какой-нибудь генератор случайной строки, но где-то в проекте уже была такая функция, и copilot её подставил. Меня это очень порадовало, потому что я сам про эту функцию не знал и городил бы свой велосипед, если бы пилот не подсказал. То есть, в отличие от кожаного мешка, резиновый мужик знает контекст лучше, и, имхо, это его большое преимущество.
Добавляю параметр в функцию
if default_server_side_encryption is not None:
if not default_server_side_encryption.mode.can_be_set_as_bucket_default():
raise WrongEncryptionModeForBucketDefault(default_server_side_encryption.mode)
kwargs['defaultServerSideEncryption'] = default_server_side_encryption.serialize_to_json_for_request()
if is_file_lock_enabled is not None:
kwargs['fileLockEnabled'] = is_file_lock_enabled
if replication_configuration is not None:
# copilot:
kwargs['replicationConfiguration'] = replication_configuration.serialize_to_json_for_request()
Я добавил replicationСonfiguration
параметр, и пилот заполнил его правильным значением, правильно обнаружив специальный метод serialize_to_json_for_request
— видимо, сделал это по аналогии со строчкой
kwargs['defaultServerSideEncryption'] = default_server_side_encryption.serialize_to_json_for_request()
Прикольно!
Эй, пандас знаешь?
node.children = {
option: cls.parse_table(table.loc[table.iloc[:, 0] == option]) # <-- copilot
for option in options
}
У меня pandas-альцгеймер, я постоянно забываю всякие методы и мне приходится перечитывать документацию. Но тут мужик по option: cls.parse_table
дополнил table.loc[table.iloc[:, 0] == option]
, и это было именно то, что нужно! Как он узнал, что мне нужно отфильтровать по определённому значению в первом столбце?!
Отфильтруй по нескольким параметрам
def get_replication_rules(
account_info: AbstractAccountInfo,
bucket_name: Optional[str] = None,
bucket_path: str = '',
replication_rule_name: Optional[str] = None,
) -> Iterator[Tuple[BucketId, ReplicationRule]]:
buckets = account_info.list_buckets()
for bucket in buckets:
# copilot:
if bucket_name and bucket.name != bucket_name:
continue
if bucket_path and bucket.path != bucket_path:
continue
if replication_rule_name and not has_rule_with_name(bucket, replication_rule_name):
continue
yield bucket.id, bucket.replication.as_replication_source.replication_rules[0]
Это хороший пример.
Тут нужно было получить список объектов и отфильтровать их по bucket_name
, bucket_path
и replication_rule_name
. Copilot это и сделал, но в конце подложил свинью: у каждого объекта может быть несколько replication_rules
, но copilot возвращает только первый. Просто он заботливый и учит вас code review и отладке.
Посчитай статистику
def count_files_and_bytes(bucket: Bucket) -> Tuple[
ReplicationCounter[ExtendedReplicationStatus],
ReplicationCounter[ExtendedReplicationStatus],
]:
"""
Calculate (counter_files, counter_bytes), where each counter
maps ExtendedReplicationStatuses to number of occurrences.
"""
counter_files = ReplicationCounter()
counter_bytes = ReplicationCounter()
# copilot:
for file_version in bucket.list_file_versions():
counter_files[file_version.status] += 1
counter_bytes[file_version.status] += file_version.size
if file_version.status == ReplicationStatus.COMPLETED:
if file_version.has_hidden_marker:
counter_files[ExtendedReplicationStatus.HAS_HIDDEN_MARKER] += 1
if file_version.has_sse_c_enabled:
counter_files[ExtendedReplicationStatus.HAS_SSE_C_ENABLED] += 1
if file_version.has_large_metadata:
counter_files[ExtendedReplicationStatus.HAS_LARGE_METADATA] += 1
return counter_files, counter_bytes
Логика подсчёта не тривиальная: мы не только заполняем значения для основного статуса типа counter_files[file_version.status] += 1
, но и добавляем подсчёт «подстатусов»: если стутус — COMPLETED
, то нужно найти его «подстатус» — HIDDEN
/SSE_C
/LARGE_METADATA
— и его тоже добавить в счётчик: counter_files[ExtendedReplicationStatus.HAS_HIDDEN_MARKER] += 1
.
И это тот случай, когда Copilot сделал всё правильно! Как он догадался — я не знаю, может, посмотрел предыдущий код, но факт: сделал именно то, что нужно.
6 казарм из 6
Время говнокодить!
Иногда пилот просто угорает. Предлагал такое не раз:
@dataclass
class BattleConfig:
copilot:
"""
BattleConfig class
"""
battle_id: int
battle_name: str
battle_type: str
battle_mode: str
battle_map: str
battle_result: str
battle_date: str
battle_time: str
battle_duration: str
battle_server: str
battle_server_url: str
battle_server_url_short: str
battle_server_url_short_2: str
battle_server_url_short_3: str
battle_server_url_short_4: str
battle_server_url_short_5: str
battle_server_url_short_6: str
battle_server_url_short_7: str
battle_server_url_short_8: str
battle_server_url_short_9: str
battle_server_url_short_10: str
battle_server_url_short_11: str
battle_server_url_short_12: str
battle_server_url_short_13: str
battle_server_url_short_14: str
battle_server_url_short_15: str
battle_server_url_short_16: str
battle_server_url_short_17: str
battle_server_url_short_18: str
battle_server_url_short_19: str
battle_server_url_short_20: str
battle_server_url_short_21: str
battle_server_url_short_22: str
battle_server_url_short_23: str
battle_server_url_short_24: str
battle_server_url_short_25: str
battle_server_url_short_26: str
battle_server_url_short_27: str
battle_server_url_short_28: str
battle_server_url_short_29: str
battle_server_url_short_30: str
battle_server_url_short_31: str
battle_server_url_short
Прям как будто в Индии побывал!
Знает про версии
Внезапно, при редактировании requirements.txt
, copilot подставил последнюю версию:
pydantic~=1.9.0
simplejson # copilot: ~=3.17.0
Не то чтобы очень полезно, но интересно.
Заткни линтер
Copilot и это умеет:
p = lambda fname: os.path.join(dir_path, fname) # noqa: copilot: E731
Сам понял, что линтер жалуется на E731
, и сам его заткнул, когда увидел noqa
.
Придумай отмазку
Где пилот действительно хорош — так это в придумывании отмазок и отговорок.
Тут написал какой-то тест и решил оправдать свою лень, и copilot отлично справился — я мог выбирать сразу несколько вариантов, почему я не хочу кодить:
def profile_switch_test(
b2_tool,
bucket_name,
folder_in_bucket,
destination_encryption=None,
expected_encryption=SSE_NONE,
source_encryption=None,
):
# this test could be unit, [copilot: but it's a bit of a pain to write]
# i did not write [copilot: this test, it was copied from the b2_cli_test.py]
# sorry for this, [copilot: but I don't want to make a new test for each]
Кстати, потом я написал такое:
# this test could be unit, but it adds a lot of complexity because of
# requirement to pass mocked B2Api to ConsoleTool; it's much easier to
# just have an [copilot: integration test]
Хоть дополнение двумя словами никак мне не помогает, но тот факт, что пилот понял контекст — что кожаный мешок не смог в юнит-тест и решил написать интеграционный — впечатляет!
К чему всё это
С резиновой женщиной всё-таки лучше
Ниже — моё мнение после нескольких месяцев с резиновым мужиком.
Почему ДА
Когда нужно сделать автодополнение одной строчки, то работает вполне прилично
На копипастах очень неплохо подставляет
Иногда, достаточно рандомно, пишет годноту и таки экономит немного времени
Подскажет подходящий метод в нужный момент, даже если вы про него забыли / не знали
Почему НЕТ
Перед каждой функцией длиннющий комментарий, который говорил пилоту, что нужно закодить. Куда этот комментарий девать? Удалять? Я не для удаления свой цветок выращивал, жалко же! Оставлять тоже тупо, он же просто описывает, что делает функция. Если не писать комментарий, а только название функции, то пилоту этого контекста может быть мало
Названия функций иногда бывают прямо из ада:
# read file exluding blank lines and lines starting with "#"
def read_file_exluding_blank_lines_and_lines_starting_with_hash(file: str) -> list:
...
Всё нужно проверять. Мужик может написать годноту, а может хрень, и никак не сигнализирует о своей уверенности.
Для коротких функций слишком накладно, проще самому написать
Совершенно, абсолютно не знает про stdlib и best practices
Так надувать его или нет?
Github Copilot — это джун без тормозов: он всегда что-то выдаёт, иногда это из stack overflow, иногда что-то неплохое, иногда полная тупость, и что вам выпадет в каждый конкретный раз — никто не знает. Вот табличка, как вам поможет резиновый мужик в зависимости от вашего опыта:
Если вы джун: учиться на copilot — себе вредить; иногда понять, что он делает, будет проблематично;
Если вы миддл: доверять copilot — себе вредить, а постоянно проверять за мужиком вы замучаетесь;
Если вы сеньор: вы всегда напишете быстрее и лучше.
Раньше я был за мужика. Типа теперь я стану кодить быстрее. Потом подумал и стал против: я боялся, что теперь нас, кодеров, вышвырнут. Машину обучают на моём коде, чтобы она меня заменила. Нет, спасибо! А теперь мне пофиг, и вот почему.
Много лет назад я пошёл на кафедру, где выбрал изучение компьютерного зрения («computer vision») — именно оттуда и родился FindFace, кстати. Я думал, что CV это пипец как круто: консервные банки что-то видят и понимают! Для меня это была технология, неотличимая от магии. Но когда лезешь «под капот», то иллюзия рушится: это были просто дескрипторы особых точек на фото и немного машинного обучения, чтобы находить паттерны в этих дескрипторах и говорить, что на фото на 60% собака и на 40% строительный кран.
Я думал, что компьютеры умеют видеть. Они и видят, но не понимают ни черта. Они и пишут — GPT-3 и вот это всё -, но не понимают ни черта. Теперь они кодят -, но всё так же ни черта не понимают.
Они словно Нео из «Матрицы» — что-то делают, но сами не знают, почему и зачем. И пока программы не научатся понимать причину, а не следствие, пока они не научатся отказываться делать бессмысленные вещи, пока не начнут предлагать альтернативные решения и думать — моя профессия в безопасности.
Если вам нравится, что я пишу, то подписывайтесь на мой канал — здесь говорят правду.